diff --git a/.changeset/1724.md b/.changeset/1724.md deleted file mode 100644 index 2ac03f94e9..0000000000 --- a/.changeset/1724.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@backstage/cli': minor -'@backstage/plugin-api-docs': minor -'@backstage/plugin-app-backend': minor -'@backstage/plugin-auth-backend': minor -'@backstage/plugin-catalog-graphql': minor -'@backstage/plugin-catalog': minor -'@backstage/plugin-circleci': minor -'@backstage/plugin-explore': minor -'@backstage/plugin-gcp-projects': minor -'@backstage/plugin-github-actions': minor -'@backstage/plugin-gitops-profiles': minor -'@backstage/plugin-graphiql': minor -'@backstage/plugin-jenkins': minor -'@backstage/plugin-kubernetes': minor -'@backstage/plugin-lighthouse': minor -'@backstage/plugin-newrelic': minor -'@backstage/plugin-register-component': minor -'@backstage/plugin-rollbar': minor -'@backstage/plugin-scaffolder': minor -'@backstage/plugin-sentry': minor -'@backstage/plugin-tech-radar': minor -'@backstage/plugin-techdocs': minor -'@backstage/plugin-welcome': minor ---- - -Create backend plugin through CLI diff --git a/.changeset/2284.md b/.changeset/2284.md deleted file mode 100644 index 655cc5a3e8..0000000000 --- a/.changeset/2284.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@backstage/core-api': minor -'@backstage/core': minor -'@backstage/plugin-auth-backend': minor ---- - -Add SAML login to backstage - -![](https://user-images.githubusercontent.com/872486/92251660-bb9e3400-eeff-11ea-86fe-1f2a0262cd31.png) - -![](https://user-images.githubusercontent.com/872486/93851658-1a76f200-fce3-11ea-990b-26ca1a327a15.png) diff --git a/.changeset/2515.md b/.changeset/2515.md deleted file mode 100644 index 32f5edd6b7..0000000000 --- a/.changeset/2515.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cloudbuild': minor ---- - -Releasing Google Cloud Build Plugin diff --git a/.changeset/2532.md b/.changeset/2532.md deleted file mode 100644 index 14307f6c08..0000000000 --- a/.changeset/2532.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/catalog-model': minor ---- - -Add handling and docs for entity references diff --git a/.changeset/2535.md b/.changeset/2535.md deleted file mode 100644 index bd28f3ce03..0000000000 --- a/.changeset/2535.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@backstage/core': patch -'@backstage/theme': patch ---- - -Fix banner position and color - -This PR closes: #2245 - -The "fixed" props added to control the position of the banner. When it is set to true the banner will be shown in bottom of that page and the width will be based on the content of the message. - -![](https://user-images.githubusercontent.com/15106494/93765685-999df480-fc15-11ea-8fa5-11cac5836cf1.png) - -![](https://user-images.githubusercontent.com/15106494/93765697-9e62a880-fc15-11ea-92af-b6a7fee4bb21.png) diff --git a/.changeset/2541.md b/.changeset/2541.md deleted file mode 100644 index 1ba29da040..0000000000 --- a/.changeset/2541.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/theme': minor ---- - -Tweak dark mode colors diff --git a/.changeset/2543.md b/.changeset/2543.md deleted file mode 100644 index 00311ade45..0000000000 --- a/.changeset/2543.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': minor ---- - -Add Azure DevOps support to the scaffolder backend - -This adds support for Azure DevOps to the scaffolder (preparer & publisher). I thought I should get this in there now since #2426 has been merged. I had a previous PR with only the preparer but I closed that in favor of this one. - -I stayed with the 'azure/api' structure but I guess we should try and go the same way as with GitHub here #2501 diff --git a/.changeset/2562.md b/.changeset/2562.md deleted file mode 100644 index 716f370ad9..0000000000 --- a/.changeset/2562.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/create-app': minor -'@backstage/plugin-auth-backend': minor -'@backstage/plugin-techdocs-backend': minor ---- - -Change the default backend plugin mount point to /api diff --git a/.changeset/2563.md b/.changeset/2563.md deleted file mode 100644 index 318d5cf5ef..0000000000 --- a/.changeset/2563.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-github-actions': minor ---- - -Adds a widget to show recent git workflow runs to the github actions plugin. The default setting is the last 5 runs across all branches but both branch and the number of runs are configurable. diff --git a/.changeset/2565.md b/.changeset/2565.md deleted file mode 100644 index 92253101ee..0000000000 --- a/.changeset/2565.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -'@backstage/core-api': patch ---- - -Add initial RouteRefRegistry - -Starting out some work to bring routing back and working as part of the work towards finalizing #1536 - -This is some of the groundwork of an experiment we're working on to enable routing via RouteRefs, while letting the app itself look something like this: - -```jsx -const App = () => ( - - - - {' '} - // catalogRouteRef - - - - - - - // statusRouteRef - - - - - - - - - - // sentryRouteRef - - - - - - - - - - - - - - - -); -``` - -As part of inverting the composition of the app, route refs and routing in general was somewhat broken, intentionally. Right now it's not really possible to easily route to different parts of the app from a plugin, or even different parts of the plugin that are not within the same router. - -The core part of the experiment is to construct a map of ApiRef[] -> path overrides. Each key in the map is the list of route refs to traversed to reach a leaf in the routing tree, and the value is the path override at that point. For example, the above tree would add entries like [techDocsRouteRef] -> '/docs', and [entityRouteRef, apiDocsRouteRef] -> '/api'. By mapping out the entire app in this structure, the idea is that we can navigate to any point in the app using RouteRefs. - -The RouteRefRegistry is an implementation of such a map, and the idea is to add it in master to make it a bit easier to experiment and iterate. This is not an exposed API at this point. - -We've explored a couple of alternatives for how to enable routing, but it's boiled down to either a solution centred around the route map mentioned above, or treating all routes as static and globally unique, with no room for flexibility, customization or conflicts between different plugins. We're starting out pursuing this options 😁. We also expect that a the app-wide routing table will make things like dynamic loading a lot cleaner, as there would be a much more clear handoff between the main chunk and dynamic chunks. diff --git a/.changeset/2575.md b/.changeset/2575.md deleted file mode 100644 index b08f50ed13..0000000000 --- a/.changeset/2575.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': minor ---- - -Fix dense in Structured Metadata Table diff --git a/.changeset/2586.md b/.changeset/2586.md deleted file mode 100644 index 568adb8a3e..0000000000 --- a/.changeset/2586.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/catalog-model': minor -'@backstage/plugin-catalog-backend': minor ---- - -Entirely case insensitive read path of entities diff --git a/.changeset/2587.md b/.changeset/2587.md deleted file mode 100644 index 910de1ca04..0000000000 --- a/.changeset/2587.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@backstage/catalog-model': minor ---- - -Add the User & Group entities - -A user describes a person, such as an employee, a contractor, or similar. Users belong to Group entities in the catalog. - -A group describes an organizational entity, such as for example a team, a business unit, or a loose collection of people in an interest group. Members of these groups are modeled in the catalog as kind User. diff --git a/.changeset/2597.md b/.changeset/2597.md deleted file mode 100644 index 9db25e8d0e..0000000000 --- a/.changeset/2597.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/catalog-model': minor -'@backstage/plugin-catalog-backend': minor ---- - -Add ApiDefinitionAtLocationProcessor that allows to load a API definition from another location diff --git a/.changeset/2598.md b/.changeset/2598.md deleted file mode 100644 index 03e10e7db8..0000000000 --- a/.changeset/2598.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@backstage/plugin-techdocs': minor ---- - -Add a message if techdocs takes long time to load - -Fixes #2416. - -The UI after the change should look like this: - -![techdocs-progress-bar](https://user-images.githubusercontent.com/33940798/94189286-296ac980-fec8-11ea-9051-1b3db938d12f.gif) diff --git a/.changeset/2600.md b/.changeset/2600.md deleted file mode 100644 index 6fc9775fa1..0000000000 --- a/.changeset/2600.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@backstage/plugin-techdocs-backend': minor -'@backstage/plugin-proxy-backend': minor -'@backstage/plugin-auth-backend': minor -'@backstage/create-app': minor -'@backstage/backend-common': minor ---- - -Add service discovery interface and implement for single host deployments - -Fixes #1847, #2596 - -Went with an interface similar to the frontend DiscoveryApi, since it's dead simple but still provides a lot of flexibility in the implementation. - -Also ended up with two different methods, one for internal endpoint discovery and one for external. The two use-cases are explained a bit more in the docs, but basically it's service-to-service vs callback URLs. - -This did get me thinking about uniqueness and that we're heading towards a global namespace for backend plugin IDs. That's probably fine, but if we're happy with that we should leverage it a bit more to simplify the backend setup. For example we'd have each plugin provide its own ID and not manually mount on paths in the backend. - -Draft until we're happy with the implementation, then I can add more docs and changelog entry. Also didn't go on a thorough hunt for places where discovery can be used, but I don't think there are many since it's been pretty awkward to do service-to-service communication. diff --git a/.changeset/2603.md b/.changeset/2603.md deleted file mode 100644 index c24d2ea033..0000000000 --- a/.changeset/2603.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/backend-common': minor -'@backstage/create-app': minor ---- - -Make CSP configurable to fix app-backend served app not being able to fetch - -See discussion [here on discord](https://discordapp.com/channels/687207715902193673/687235481154617364/758721460163575850) diff --git a/.changeset/2606.md b/.changeset/2606.md deleted file mode 100644 index f6059e7705..0000000000 --- a/.changeset/2606.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Move auth provider router creation to router diff --git a/.changeset/2609.md b/.changeset/2609.md deleted file mode 100644 index 6bb8ab7a40..0000000000 --- a/.changeset/2609.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Sync scaffolded backend with example diff --git a/.changeset/2610.md b/.changeset/2610.md deleted file mode 100644 index 92ec8dcc0c..0000000000 --- a/.changeset/2610.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-circleci': minor ---- - -Refactor to use DiscoveryApi diff --git a/.changeset/2611.md b/.changeset/2611.md deleted file mode 100644 index cbf3129038..0000000000 --- a/.changeset/2611.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Remove discovery api override diff --git a/.changeset/2612.md b/.changeset/2612.md deleted file mode 100644 index bf093d8f77..0000000000 --- a/.changeset/2612.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-jenkins': patch ---- - -Refactor to use DiscoveryApi diff --git a/.changeset/2613.md b/.changeset/2613.md deleted file mode 100644 index cf8c1a155a..0000000000 --- a/.changeset/2613.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@backstage/plugin-auth-backend': minor ---- - -Initial implementation of catalog user lookup - -This adds a basic catalog client + method for the Google provider to look up users in the catalog. It expects to find a single user entity in the catalog with a google.com/email annotation that matches the email of the Google profile. - -Right now it falls back to the old behavior of splitting the email, since I don't wanna break the sign-in flow for existing apps, not yet anyway x). - -- Added "@backstage/catalog-model@^0.1.1-alpha.23" as a dependency -- Added "node-fetch@^2.6.1" as a dependency diff --git a/.changeset/2614.md b/.changeset/2614.md deleted file mode 100644 index 7e2660bc80..0000000000 --- a/.changeset/2614.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': minor ---- - -Default to using internal scope for new plugins diff --git a/.changeset/2615.md b/.changeset/2615.md deleted file mode 100644 index 412b8127c4..0000000000 --- a/.changeset/2615.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': minor ---- - -Use localhost to fall back to IPv4 if IPv6 isn't available diff --git a/.changeset/2616.md b/.changeset/2616.md deleted file mode 100644 index 9a70c0e79f..0000000000 --- a/.changeset/2616.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/create-app': minor ---- - -Remove identity-backend - -Not used, and we're heading down the route of identities in the catalog diff --git a/.changeset/2623.md b/.changeset/2623.md deleted file mode 100644 index abd5ce7a4c..0000000000 --- a/.changeset/2623.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-catalog': minor ---- - -Created EntityNotFound component for catalog which displays the 404 page when entity is not found. - -Fixes #2266 diff --git a/.changeset/2624.md b/.changeset/2624.md deleted file mode 100644 index dcfc800713..0000000000 --- a/.changeset/2624.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Make title meaningful after component creation - -Fixes #2458. - -After the change, the UX should look like this: - -### If the component creation was successful: - -![successfully-created-component](https://user-images.githubusercontent.com/33940798/94339294-8bd1e000-0016-11eb-885b-7936fcc23b63.gif) - -### If the component creation failed: - -![failed-to-create-component](https://user-images.githubusercontent.com/33940798/94339296-90969400-0016-11eb-9a74-ce16b3dd8d88.gif) diff --git a/.changeset/2625-catalog-backend.md b/.changeset/2625-catalog-backend.md deleted file mode 100644 index bcd49dddb2..0000000000 --- a/.changeset/2625-catalog-backend.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Add codeowners processor - -- Add `codeowners-utils@^1.0.2` as a dependency -- Add `core-js@^3.6.5` as a dependency -- Added new CodeOwnersProcessor diff --git a/.changeset/2625-cli.md b/.changeset/2625-cli.md deleted file mode 100644 index c5a5437b69..0000000000 --- a/.changeset/2625-cli.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Add codeowners processor - -- Include ESNext.Promise in TypeScript compilation diff --git a/.changeset/2628.md b/.changeset/2628.md deleted file mode 100644 index 0ac36d456a..0000000000 --- a/.changeset/2628.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor ---- - -This feature works the same as \$secret does in config - it allows programmatic substitution of values into a document. - -This is particularly useful e.g. for API type entities where you do not want to repeat your entire API spec document inside the catalog-info.yaml file. For those cases, you can instead do something like - -``` -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: my-federated-service -spec: - type: graphql - definition: - $text: ./schema.graphql -``` - -The textual content of that file will be injected as the value of definition, during each refresh loop. Both relative and absolute paths are supported, as well as any HTTP/HTTPS URL pointing to a service that returns the relevant data. - -The initial version supports injection of text file data, and structured data from JSON and YAML files. You can add any handler of your own in addition to these. diff --git a/.changeset/2630.md b/.changeset/2630.md deleted file mode 100644 index 933204e292..0000000000 --- a/.changeset/2630.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@backstage/create-app': minor -'@backstage/plugin-catalog-backend': minor ---- - -Allow node v14 and add to master build matrix - -- Upgrade sqlite3@^5.0.0 in @backstage/plugin-catalog-backend -- Add Node 14 to engines in @backstage/create-app diff --git a/.changeset/2637.md b/.changeset/2637.md deleted file mode 100644 index 9d368634b6..0000000000 --- a/.changeset/2637.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@backstage/plugin-cost-insights': minor -'@backstage/plugin-explore': minor ---- - -This PR adds Spotify's Cost Insights Tool. Cost Insights explains costs from cloud services in an understandable way, using software terms familiar to your engineers. This tool helps you and your team make trade-offs between cost optimization efforts and your other priorities. - -Cost Insights features: - -Daily cost graph by team or billing account -Cost comparison against configurable business metrics -Insights panels for configurable cloud products your company uses -Cost alerts and recommendations -Selectable time periods for month over month, or quarter over quarter cost comparison -Conversion of cost growth into average engineer cost (configurable) to help optimization trade-off decisions - -![plugin-cost-insights](https://user-images.githubusercontent.com/3030003/94430416-e166d380-0161-11eb-891c-9ce10187683e.gif) - -This PR adds the Cost Insights frontend React plugin with a defined CostInsightsApi. We include an example client with static data in the expected format. This API should talk with a cloud billing backend that aggregates billing data from your cloud provider. - -Fixes #688 💵 diff --git a/.changeset/2639.md b/.changeset/2639.md deleted file mode 100644 index e6a623088e..0000000000 --- a/.changeset/2639.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': minor ---- - -Upgrade dependency `esbuild@0.7.7` diff --git a/.changeset/2641.md b/.changeset/2641.md deleted file mode 100644 index 3b06358e81..0000000000 --- a/.changeset/2641.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor ---- - -Simplify the read function in processors diff --git a/.changeset/2656.md b/.changeset/2656.md deleted file mode 100644 index e253b6b1f6..0000000000 --- a/.changeset/2656.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': minor ---- - -Lookup user in Google Auth Provider diff --git a/.changeset/2657.md b/.changeset/2657.md deleted file mode 100644 index 9aa645d65f..0000000000 --- a/.changeset/2657.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': minor ---- - -Added EmptyState component diff --git a/.changeset/2660.md b/.changeset/2660.md deleted file mode 100644 index 758d523f07..0000000000 --- a/.changeset/2660.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-api-docs': patch ---- - -Resolve some dark mode styling issues in asyncAPI specs diff --git a/.changeset/2661.md b/.changeset/2661.md deleted file mode 100644 index e73b0abf26..0000000000 --- a/.changeset/2661.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Fixed banner component position in DismissableBanner component diff --git a/.changeset/2669-catalog-backend.md b/.changeset/2669-catalog-backend.md deleted file mode 100644 index 32ee5c0f4d..0000000000 --- a/.changeset/2669-catalog-backend.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor ---- - -Add the ability to import users from GitHub Organization into the catalog. - -The token needs to have the scopes `user:email`, `read:user`, and `read:org`. diff --git a/.changeset/2669-catalog-model.md b/.changeset/2669-catalog-model.md deleted file mode 100644 index b9a2841a1b..0000000000 --- a/.changeset/2669-catalog-model.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/catalog-model': minor ---- - -Add the ability to import users from GitHub Organization into the catalog. diff --git a/.changeset/2669-create-app.md b/.changeset/2669-create-app.md deleted file mode 100644 index e7075ffeb8..0000000000 --- a/.changeset/2669-create-app.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/create-app': minor ---- - -Add the ability to import users from GitHub Organization into the catalog. - -The token needs to have the scopes `user:email`, `read:user`, and `read:org`. diff --git a/.changeset/2674.md b/.changeset/2674.md deleted file mode 100644 index 15a7a0e849..0000000000 --- a/.changeset/2674.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@backstage/backend-common': minor -'@backstage/create-app': minor ---- - -Auto-create plugin databases - -Relates to #1598. - -This creates databases for plugins before handing off control to plugins. - -The list of plugins currently need to be hard-coded depending on the installed plugins. A later PR will properly refactor the code to provide a factory pattern where plugins specify what they need, and Knex instances will be provided based on the input. diff --git a/.changeset/2686.md b/.changeset/2686.md deleted file mode 100644 index 8831c1309b..0000000000 --- a/.changeset/2686.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Update SSR template to pass CI diff --git a/.changeset/2689.md b/.changeset/2689.md deleted file mode 100644 index be75dd261b..0000000000 --- a/.changeset/2689.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Upgrade dependency rollup-plugin-typescript2 to ^0.27.3 diff --git a/.changeset/2722.md b/.changeset/2722.md deleted file mode 100644 index 69642b5797..0000000000 --- a/.changeset/2722.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@backstage/plugin-api-docs': minor ---- - -There were some missing features and markdown was not rendered properly, but this is fixed now. - -Details: - -- [`asyncapi/asyncapi-react#149`](https://github.com/asyncapi/asyncapi-react/pull/149) - fix: improve markdown rendering of nested fields -- [`asyncapi/asyncapi-react#150`](https://github.com/asyncapi/asyncapi-react/pull/150) - feat: display the description of channels and operations -- [`asyncapi/asyncapi-react#153`](https://github.com/asyncapi/asyncapi-react/pull/153) - fix: let the list of `enums` break into multiple lines diff --git a/.changeset/config.json b/.changeset/config.json index c7c4f11e57..44a8523265 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,55 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@1.3.0/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "linked": [ - [ - "example-app", - "@backstage/backend-common", - "example-backend", - "@backstage/catalog-model", - "@backstage/cli-common", - "@backstage/cli", - "@backstage/config-loader", - "@backstage/config", - "@backstage/core-api", - "@backstage/core", - "@backstage/create-app", - "@backstage/dev-utils", - "docgen", - "e2e-test", - "storybook", - "@techdocs/cli", - "@backstage/test-utils-core", - "@backstage/test-utils", - "@backstage/theme", - "@backstage/plugin-api-docs", - "@backstage/plugin-app-backend", - "@backstage/plugin-auth-backend", - "@backstage/plugin-catalog-backend", - "@backstage/plugin-catalog", - "@backstage/plugin-circleci", - "@backstage/plugin-gcp-projects", - "@backstage/plugin-github-actions", - "@backstage/plugin-gitops-profiles", - "@backstage/plugin-graphiql", - "@backstage/plugin-graphql-backend", - "@backstage/plugin-jenkins", - "@backstage/plugin-lighthouse", - "@backstage/plugin-newrelic", - "@backstage/plugin-proxy-backend", - "@backstage/plugin-register-component", - "@backstage/plugin-rollbar-backend", - "@backstage/plugin-rollbar", - "@backstage/plugin-scaffolder-backend", - "@backstage/plugin-scaffolder", - "@backstage/plugin-sentry-backend", - "@backstage/plugin-sentry", - "@backstage/plugin-tech-radar", - "@backstage/plugin-techdocs-backend", - "@backstage/plugin-techdocs", - "@backstage/plugin-welcome" - ] - ], + "linked": [["*"]], "access": "public", "baseBranch": "master", "updateInternalDependencies": "patch", diff --git a/.changeset/cost-insights-quiet-bikes-occur.md b/.changeset/cost-insights-quiet-bikes-occur.md new file mode 100644 index 0000000000..aac283bbd4 --- /dev/null +++ b/.changeset/cost-insights-quiet-bikes-occur.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +fix product icon configuration diff --git a/.changeset/create-app-url-reader-update.md b/.changeset/create-app-url-reader-update.md deleted file mode 100644 index eb2e538b77..0000000000 --- a/.changeset/create-app-url-reader-update.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'example-backend': patch -'@backstage/create-app': patch ---- - -Bump @backstage/catalog-backend and pass the now required UrlReader interface to the plugin diff --git a/.changeset/dull-pans-sip.md b/.changeset/dull-pans-sip.md new file mode 100644 index 0000000000..13616c1f8d --- /dev/null +++ b/.changeset/dull-pans-sip.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Move constructing the catalog-info.yaml URL for scaffolded components to the publishers diff --git a/.changeset/empty-kids-look.md b/.changeset/empty-kids-look.md new file mode 100644 index 0000000000..680cd7f25c --- /dev/null +++ b/.changeset/empty-kids-look.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-register-component': patch +--- + +Remove catalog link on validate popup diff --git a/.changeset/metal-fishes-learn.md b/.changeset/metal-fishes-learn.md deleted file mode 100644 index d22af83ca0..0000000000 --- a/.changeset/metal-fishes-learn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': minor ---- - -The default mount point for backend plugins have been changed to /api. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern. diff --git a/.changeset/new-url-reader.md b/.changeset/new-url-reader.md deleted file mode 100644 index 94758c5bc1..0000000000 --- a/.changeset/new-url-reader.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Added new UrlReader interface for reading opaque data from URLs with different providers. - -This new URL reading system is intended as a replacement for the various integrations towards -external systems in the catalog, scaffolder, and techdocs. It is configured via a new top-level -config section called 'integrations'. - -Along with the UrlReader interface is a new UrlReaders class, which exposes static factory -methods for instantiating readers that can read from many different integrations simultaneously. diff --git a/.changeset/short-secrets.md b/.changeset/short-secrets.md deleted file mode 100644 index 72cb059979..0000000000 --- a/.changeset/short-secrets.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/config-loader': minor ---- - -Added support for new shorthand when defining secrets, where `$env: ENV` can be used instead of `$secret: { env: ENV }` etc. diff --git a/.changeset/url-reader-processor.md b/.changeset/url-reader-processor.md deleted file mode 100644 index 40fef3c106..0000000000 --- a/.changeset/url-reader-processor.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor ---- - -The catalog backend UrlReaderProcessor now uses a UrlReader from @backstage/backend-common, which must now be supplied to the constructor. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3df1031034..b3c9bc653d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,13 +4,11 @@ # The last matching pattern takes precedence. # https://help.github.com/articles/about-codeowners/ -* @spotify/backstage-core -/docs/features/techdocs @spotify/techdocs-core -/plugins/cost-insights @spotify/silver-lining +* @backstage/maintainers +/docs/features/techdocs @backstage/techdocs-core +/plugins/cost-insights @backstage/silver-lining /plugins/cloudbuild @trivago/ebarrios -/plugins/techdocs @spotify/techdocs-core -/plugins/techdocs-backend @spotify/techdocs-core -/packages/techdocs-cli @spotify/techdocs-core -/packages/techdocs-container @spotify/techdocs-core -/.github/workflows/techdocs.yml @spotify/techdocs-core -/.github/workflows/techdocs-pypi.yml @spotify/techdocs-core +/plugins/techdocs @backstage/techdocs-core +/plugins/search @backstage/techdocs-core +/plugins/techdocs-backend @backstage/techdocs-core +/.changeset/cost-insights-* @backstage/silver-lining diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0437698301..d68af23491 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,11 +5,9 @@ #### :heavy_check_mark: Checklist - + -- [ ] All tests are passing `yarn test` +- [ ] A changeset describing the change and affected packages. ([more info](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#creating-changesets)) +- [ ] Added or updated documentation +- [ ] Tests for new functionality and regression tests for bug fixes - [ ] Screenshots attached (for UI changes) -- [ ] Relevant documentation updated -- [ ] Prettier run on changed files -- [ ] Tests added for new functionality -- [ ] Regression tests added for bug fixes diff --git a/.codecov.yml b/.github/codecov.yml similarity index 92% rename from .codecov.yml rename to .github/codecov.yml index 1b8e770b59..d39038615f 100644 --- a/.codecov.yml +++ b/.github/codecov.yml @@ -6,6 +6,7 @@ coverage: default: threshold: 0% # Ref: https://docs.codecov.io/docs/codecovyml-reference#coveragestatus target: auto + informational: true # Don't block PRs # Since Backstage is a mono repo, flags here help in getting the code coverage of individual packages. # Documentation: https://docs.codecov.io/docs/flags diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index c9774ae237..dcd10e5266 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -3,12 +3,14 @@ Apdex api Api apis +andrewthauer args asciidoc async Avro backrub Balachandran +benjdlambert Bigtable Blackbox bool @@ -50,6 +52,7 @@ Dockerfile Dockerize dockerode Docusaurus +dzolotusky eg Ek env @@ -59,21 +62,26 @@ facto failover Figma Firekube +freben Fredrik github Github +gitlab Gitlab graphql graphviz +Gustavsson Hackathons haproxy heroku +Heroku horizontalpodautoscalers Hostname http https img incentivised +inlined inlinehilite interop javascript @@ -90,6 +98,7 @@ learnings lerna Lerna magiclink +Maintainership mailto Malus md @@ -105,6 +114,7 @@ Monorepo monorepos msw namespace +namespaces Namespaces neuro newrelic @@ -141,6 +151,7 @@ Raghunandan rankdir readme Readme +Recharts Redash replicasets repo @@ -153,12 +164,14 @@ Rollup Rosaceae rst rsync +rugvip ruleset sam scaffolded scaffolder Scaffolder semlas +semver Serverless Sinon smartsymobls @@ -168,9 +181,11 @@ spotify Spotify squidfunk src +stefanalund subkey superfences Superfences +superset talkdesk Talkdesk tasklist @@ -185,9 +200,13 @@ theres toc tolerations Tolerations +toolchain toolsets +tooltip +tooltips touchpoints ui +untracked upvote url utils @@ -195,12 +214,16 @@ validators Voi Wealthsimple Weaveworks +Webpack xyz yaml Zalando Zhou +Zolotusky Billett cloudbuild Grafana Iain Snyk +www +WWW diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index ac7872ea93..a557b4921b 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -15,5 +15,8 @@ jobs: run: yarn --frozen-lockfile - name: Create Release Pull Request uses: changesets/action@master + with: + # Calls out to `changeset version`, but also runs prettier + version: yarn release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65e092676d..c35e2a88ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,11 +67,14 @@ jobs: continue-on-error: true - name: verify doc links - run: node docs/verify-links.js + run: node scripts/verify-links.js - name: prettier run: yarn prettier:check + - name: validate config + run: yarn backstage-cli config:check + - name: lint run: yarn lerna -- run lint --since origin/master @@ -97,7 +100,7 @@ jobs: if: ${{ steps.yarn-lock.outcome == 'failure' }} run: | yarn lerna -- run test -- --coverage - bash <(curl -s https://codecov.io/bash) + bash <(curl -s https://codecov.io/bash) -N $(git rev-parse FETCH_HEAD) - name: verify plugin template run: yarn lerna -- run diff -- --check diff --git a/.github/workflows/e2e-win.yml b/.github/workflows/e2e-win.yml index c48d779962..1844700cea 100644 --- a/.github/workflows/e2e-win.yml +++ b/.github/workflows/e2e-win.yml @@ -32,7 +32,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: Add msbuild to PATH - uses: microsoft/setup-msbuild@v1.0.1 + uses: microsoft/setup-msbuild@v1.0.2 - name: yarn install run: yarn install --frozen-lockfile @@ -40,4 +40,4 @@ jobs: - name: yarn build run: yarn build --ignore example-app --ignore example-backend --ignore @techdocs/cli - name: run E2E test - run: yarn workspace e2e-test start + run: yarn e2e-test run diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e280bf59a3..62be0a6cea 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -70,7 +70,7 @@ jobs: - name: run E2E test run: | sudo sysctl fs.inotify.max_user_watches=524288 - yarn workspace e2e-test start + yarn e2e-test run env: POSTGRES_HOST: localhost POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index f5029bf836..ca63e627e9 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -47,6 +47,9 @@ jobs: run: yarn install --frozen-lockfile # End of yarn setup + - name: validate config + run: yarn backstage-cli config:check + - name: lint run: yarn lerna -- run lint diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 335bd80109..3f59b815b3 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -6,6 +6,8 @@ on: jobs: build: + if: github.repository == 'backstage/backstage' # prevent running on forks + runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/techdocs-project-board.yml b/.github/workflows/techdocs-project-board.yml index b389cf6bf5..a8d476f713 100644 --- a/.github/workflows/techdocs-project-board.yml +++ b/.github/workflows/techdocs-project-board.yml @@ -1,6 +1,7 @@ name: Automatically add new TechDocs Issues and PRs to the GitHub project board -# Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/spotify/backstage/projects/5 -# New issues with TechDocs in their title or docs-like-code label will be added to the board. +# Development of TechDocs in Backstage is managed by this Kanban board - https://github.com/orgs/backstage/projects/1 +# New issues and PRs with TechDocs in their title or docs-like-code label will be added to the board. +# Caveat: New PRs created from forks will not be added since GitHub actions don't share credentials with forks. on: issues: @@ -9,7 +10,7 @@ on: types: [opened, reopened, labeled, edited] env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MY_GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} jobs: assign_issue_or_pr_to_project: @@ -23,7 +24,7 @@ jobs: contains(github.event.issue.title, 'techdocs') || contains(github.event.issue.title, 'Techdocs') with: - project: 'https://github.com/spotify/backstage/projects/5' + project: 'https://github.com/orgs/backstage/projects/1' column_name: 'Incoming' - name: Assign new issue to Incoming based on its label. @@ -31,7 +32,7 @@ jobs: if: | contains(github.event.issue.labels.*.name, 'docs-like-code') with: - project: 'https://github.com/spotify/backstage/projects/5' + project: 'https://github.com/orgs/backstage/projects/1' column_name: 'Incoming' - name: Assign new PR to Incoming based on its title. @@ -41,7 +42,7 @@ jobs: contains(github.event.pull_request.title, 'techdocs') || contains(github.event.pull_request.title, 'Techdocs') with: - project: 'https://github.com/spotify/backstage/projects/5' + project: 'https://github.com/orgs/backstage/projects/1' column_name: 'Incoming' - name: Assign new PR to Incoming based on its label. @@ -49,5 +50,5 @@ jobs: if: | contains(github.event.pull_request.labels.*.name, 'docs-like-code') with: - project: 'https://github.com/spotify/backstage/projects/5' + project: 'https://github.com/orgs/backstage/projects/1' column_name: 'Incoming' diff --git a/.github/workflows/techdocs-pypi.yml b/.github/workflows/techdocs-pypi.yml deleted file mode 100644 index 7680cb98b0..0000000000 --- a/.github/workflows/techdocs-pypi.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Master Build TechDocs PyPI Publish - -on: - push: - branches: [master] - paths: - - '.github/workflows/techdocs-pypi.yml' - - 'packages/techdocs-container/**' - -jobs: - build: - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ubuntu-latest] - python-version: [3.7] - - steps: - # Publish techdocs-core to PyPI - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@master - with: - python-version: 3.7 - - name: Build Python distribution - working-directory: ./packages/techdocs-container/techdocs-core - run: | - pip install wheel - rm -rf dist - python setup.py bdist_wheel sdist --formats gztar - - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - user: __token__ - password: ${{ secrets.PYPI_API_KEY }} - packages_dir: ./packages/techdocs-container/techdocs-core/dist diff --git a/.github/workflows/techdocs.yml b/.github/workflows/techdocs.yml deleted file mode 100644 index 95b68c9376..0000000000 --- a/.github/workflows/techdocs.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: TechDocs - -on: - pull_request: - paths: - - '.github/workflows/techdocs.yml' - - 'packages/techdocs-container/**' - - 'packages/techdocs-cli/**' - - 'plugins/techdocs/**' - - 'plugins/techdocs-backend/**' - -jobs: - build: - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ubuntu-latest] - python-version: [3.7] - - env: - TECHDOCS_CORE_PATH: ./packages/techdocs-container/techdocs-core - - name: Python ${{ matrix.node-version }} on ${{ matrix.os }} - steps: - - uses: actions/checkout@v2 - - # Build Docker Image - - name: Build Docker image - uses: docker/build-push-action@v1.1.0 - with: - path: packages/techdocs-container - push: false - - # Lint Python code for techdocs-core package - - name: Prepare Python environment - run: | - python3 -m pip install --index-url https://pypi.org/simple/ setuptools - python3 -m pip install --upgrade pip - python3 -m pip install --index-url https://pypi.org/simple/ -r $TECHDOCS_CORE_PATH/requirements.txt - - - name: Lint techdocs-core package - run: | - python3 -m black --check $TECHDOCS_CORE_PATH/src diff --git a/.gitignore b/.gitignore index e7d294d55a..5c27601791 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ -.idea/ +# macOS .DS_Store + +# IntelliJ +.idea/ +*.iml + +# Visual Studio Code .vscode/ .vsls.json diff --git a/.prettierignore b/.prettierignore index 4b1acbb594..c4e675a5d7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ coverage templates plugins/scaffolder-backend/sample-templates .vscode +dist-types diff --git a/.yarnrc b/.yarnrc index f465c5c5e6..6b56b971d7 100644 --- a/.yarnrc +++ b/.yarnrc @@ -3,6 +3,7 @@ registry "https://registry.npmjs.org/" +disable-self-update-check true lastUpdateCheck 1580389148099 yarn-path ".yarn/releases/yarn-1.22.1.js" network-timeout 600000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1860a4de6f..1ac33ca7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,22 +2,42 @@ This is a best-effort changelog where we manually collect breaking changes. It is not an exhaustive list of all changes or even features added. -If you encounter issues while upgrading to a newer version, don't hesitate to reach out on [Discord](https://discord.gg/EBHEGzX) or [open an issue](https://github.com/spotify/backstage/issues/new/choose)! +If you encounter issues while upgrading to a newer version, don't hesitate to reach out on [Discord](https://discord.gg/EBHEGzX) or [open an issue](https://github.com/backstage/backstage/issues/new/choose)! ## Next Release > Collect changes for the next release below +## v0.1.1-alpha.26 + +### @backstage/cli + +- Configuration files are no longer selected through `APP_ENV` or `NODE_ENV`. The default configuration files are `app-config.yaml` and, fix it exists, `app-config.local.yaml` in the repo root. To load a different set of files, use `--config ` arguments. + +### @backstage/backend-common + +- Configuration files are no longer selected through `APP_ENV` or `NODE_ENV`. The default configuration files are `app-config.yaml` and, fix it exists, `app-config.local.yaml` in the repo root. To load a different set of files, use `--config ` arguments. + +## v0.1.1-alpha.25 + +### @backstage/cli + +- The recommended way to set the configuration environment is now to use `APP_ENV` instead of `NODE_ENV`. + +### Backend (example-backend, or backends created with @backstage/create-app) + +- A plugin database manager has been created, and plugins can now accept that interface as an argument during initialisation. Notably, the `auth` plugin has a [`createRouter` signature change](./plugins/auth-backend/src/service/router.ts). See [packages/backend/src/index.ts](./packages/backend/src/index.ts) on how to set it up. [#2697](https://github.com/backstage/backstage/pull/2697) + ## v0.1.1-alpha.24 ### Backend (example-backend, or backends created with @backstage/create-app) -- The default mount point for backend plugins have been changed to `/api`. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern. [#2562](https://github.com/spotify/backstage/pull/2562) -- A service discovery mechanism for backend plugins has been added, and is now a requirement for several backend plugins. See [packages/backend/src/index.ts](./packages/backend/src/index.ts) for how to set it up using `SingleHostDiscovery` from `@backstage/backend-common`. Note that the default base path for plugins is set to `/api` to that change, but it can be set to use the old behavior via the `basePath` option. [#2600](https://github.com/spotify/backstage/pull/2600) +- The default mount point for backend plugins have been changed to `/api`. These changes are done in the backend package itself, so it is recommended that you sync up existing backend packages with this new pattern. [#2562](https://github.com/backstage/backstage/pull/2562) +- A service discovery mechanism for backend plugins has been added, and is now a requirement for several backend plugins. See [packages/backend/src/index.ts](./packages/backend/src/index.ts) for how to set it up using `SingleHostDiscovery` from `@backstage/backend-common`. Note that the default base path for plugins is set to `/api` to that change, but it can be set to use the old behavior via the `basePath` option. [#2600](https://github.com/backstage/backstage/pull/2600) ### @backstage/auth-backend -- The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/spotify/backstage/pull/2562) +- The default mount path of backend plugins was changed to `/api/:pluginId`, and as part of that it was needed to enable configuration of the base path of the auth backend, so that it can construct redirect URLs correctly. Note that you will also need to reconfigure any allowed redirect URLs to include `/api` if you switch to the new recommended pattern. [#2562](https://github.com/backstage/backstage/pull/2562) - The auth backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. ### @backstage/proxy-backend @@ -30,28 +50,28 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re ### @backstage/plugin-identity-backend -- This plugin was removed, remove it from your backend if it's there. [#2616](https://github.com/spotify/backstage/pull/2616) +- This plugin was removed, remove it from your backend if it's there. [#2616](https://github.com/backstage/backstage/pull/2616) ## v0.1.1-alpha.23 ### @backstage/core -- Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/spotify/backstage/pull/2555). +- Renamed `SessionStateApi` to `SessionApi` and `logout` to `signOut`. Custom implementations of the `SingInPage` app-component will need to rename their `logout` function. The different auth provider items for the `UserSettingsMenu` have been consolidated into a single `ProviderSettingsItem`, meaning you need to replace existing usages of `OAuthProviderSettings` and `OIDCProviderSettings`. [#2555](https://github.com/backstage/backstage/pull/2555). ## v0.1.1-alpha.22 ### @backstage/core -- Introduced initial version of an inverted app/plugin relationship, where plugins export components for apps to use, instead registering themselves directly into the app. This enables more fine-grained control of plugin features, and also composition of plugins such as catalog pages with additional cards and tabs. This breaks the use of `RouteRef`s, and there will be more changes related to this in the future, but this change lays the initial foundation. See `packages/app` and followup PRs for how to update plugins for this change. [#2076](https://github.com/spotify/backstage/pull/2076) -- Switch to an automatic dependency injection mechanism for all Utility APIs, allowing plugins to ship default implementations of their APIs. See [https://backstage.io/docs/api/utility-apis](https://backstage.io/docs/api/utility-apis). [#2285](https://github.com/spotify/backstage/pull/2285) +- Introduced initial version of an inverted app/plugin relationship, where plugins export components for apps to use, instead registering themselves directly into the app. This enables more fine-grained control of plugin features, and also composition of plugins such as catalog pages with additional cards and tabs. This breaks the use of `RouteRef`s, and there will be more changes related to this in the future, but this change lays the initial foundation. See `packages/app` and followup PRs for how to update plugins for this change. [#2076](https://github.com/backstage/backstage/pull/2076) +- Switch to an automatic dependency injection mechanism for all Utility APIs, allowing plugins to ship default implementations of their APIs. See [https://backstage.io/docs/api/utility-apis](https://backstage.io/docs/api/utility-apis). [#2285](https://github.com/backstage/backstage/pull/2285) ### @backstage/cli -- Change `backstage-cli backend:build-image` to forward all args to `docker image build`, instead of just tag. Also add `--build` flag for building all dependent packages before packaging the workspace for the docker build. [#2299](https://github.com/spotify/backstage/pull/2299) +- Change `backstage-cli backend:build-image` to forward all args to `docker image build`, instead of just tag. Also add `--build` flag for building all dependent packages before packaging the workspace for the docker build. [#2299](https://github.com/backstage/backstage/pull/2299) ### @backstage/create-app -- Change root `tsc` output dir to `dist-types`, in order to allow for standalone plugin repos. [#2278](https://github.com/spotify/backstage/pull/2278) +- Change root `tsc` output dir to `dist-types`, in order to allow for standalone plugin repos. [#2278](https://github.com/backstage/backstage/pull/2278) ### @backstage/catalog-backend @@ -59,7 +79,7 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re ## v0.1.1-alpha.21 -- Added many more frontend plugins to the template along with the sidebar. [#1942](https://github.com/spotify/backstage/pull/1942), [#2084](https://github.com/spotify/backstage/pull/2084) +- Added many more frontend plugins to the template along with the sidebar. [#1942](https://github.com/backstage/backstage/pull/1942), [#2084](https://github.com/backstage/backstage/pull/2084) ### @backstage/core @@ -70,14 +90,14 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re ### @backstage/cli -- Set `NODE_ENV` to `test` when running test. [#2214](https://github.com/spotify/backstage/pull/2214) +- Set `NODE_ENV` to `test` when running test. [#2214](https://github.com/backstage/backstage/pull/2214) -- Fix for backend plugins names requiring to be prefixed with `@backstage` to build. [#2224](https://github.com/spotify/backstage/pull/2224) +- Fix for backend plugins names requiring to be prefixed with `@backstage` to build. [#2224](https://github.com/backstage/backstage/pull/2224) ### @backstage/backend-common - The backend plugin - [service builder](https://github.com/spotify/backstage/blob/master/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts) + [service builder](https://github.com/backstage/backstage/blob/master/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts) no longer adds `express.json()` automatically to all routes. While convenient in a lot of cases, it also led to problems where for example the proxy middleware could hang because the body had already been altered and could not @@ -87,61 +107,61 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re ### @backstage/catalog-backend -- Add rules configuration for catalog location and entity kinds. The default rules should cover most use-cases, but you may need to allow specific entity kinds when using things like Template or Group entities. [#2118](https://github.com/spotify/backstage/pull/2118) +- Add rules configuration for catalog location and entity kinds. The default rules should cover most use-cases, but you may need to allow specific entity kinds when using things like Template or Group entities. [#2118](https://github.com/backstage/backstage/pull/2118) ## v0.1.1-alpha.20 ### @backstage/cli -- Use config files according to `NODE_ENV` when serving and building frontend packages. [#2077](https://github.com/spotify/backstage/pull/2077) +- Use config files according to `NODE_ENV` when serving and building frontend packages. [#2077](https://github.com/backstage/backstage/pull/2077) -- Pin `rollup-plugin-dts` to avoid a later broken version. [#2097](https://github.com/spotify/backstage/pull/2097) +- Pin `rollup-plugin-dts` to avoid a later broken version. [#2097](https://github.com/backstage/backstage/pull/2097) ## v0.1.1-alpha.19 ### @backstage/backend-common -- Allow listen host and port to be configured separately, in order to support PORT environment variables. [#1950](https://github.com/spotify/backstage/pull/1950) +- Allow listen host and port to be configured separately, in order to support PORT environment variables. [#1950](https://github.com/backstage/backstage/pull/1950) ### @backstage/core -- Added new `DiscoveryApi` for discovering backend endpoint in the frontend, and use in most plugins. See [packages/app/src/apis.ts](https://github.com/spotify/backstage/blob/master/packages/app/src/apis.ts) for how to register in your app. [#2074](https://github.com/spotify/backstage/pull/2074) +- Added new `DiscoveryApi` for discovering backend endpoint in the frontend, and use in most plugins. See [packages/app/src/apis.ts](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts) for how to register in your app. [#2074](https://github.com/backstage/backstage/pull/2074) ### @backstage/create-app -- Added catalog and scaffolder frontend plugins to the template along with the sidebar. [#1942](https://github.com/spotify/backstage/pull/1942), [#2084](https://github.com/spotify/backstage/pull/2084) -- Many plugins have been added to the catalog and will for now be required to be added to separate apps as well. This will be solved as [#1536](https://github.com/spotify/backstage/issues/1536) gets sorted out, but for now you may need to install some plugins just to get pages to work. +- Added catalog and scaffolder frontend plugins to the template along with the sidebar. [#1942](https://github.com/backstage/backstage/pull/1942), [#2084](https://github.com/backstage/backstage/pull/2084) +- Many plugins have been added to the catalog and will for now be required to be added to separate apps as well. This will be solved as [#1536](https://github.com/backstage/backstage/issues/1536) gets sorted out, but for now you may need to install some plugins just to get pages to work. ### @backstage/catalog-backend -- Added the possibility to add static locations via `app-config.yaml`. This changed the signature of `new LocationReaders(logger)` inside `packages/backend/src/plugins/catalog.ts` to `new LocationReaders({config, logger})`. [#1890](https://github.com/spotify/backstage/pull/1890) +- Added the possibility to add static locations via `app-config.yaml`. This changed the signature of `new LocationReaders(logger)` inside `packages/backend/src/plugins/catalog.ts` to `new LocationReaders({config, logger})`. [#1890](https://github.com/backstage/backstage/pull/1890) ### @backstage/theme -- Changed the type signature of the palette, removing `sidebar: string` and adding `navigation: { background: string; indicator: string}`. [#1880](https://github.com/spotify/backstage/pull/1880) +- Changed the type signature of the palette, removing `sidebar: string` and adding `navigation: { background: string; indicator: string}`. [#1880](https://github.com/backstage/backstage/pull/1880) ## v0.1.1-alpha.18 ### @backstage/catalog-backend -- Fixed an issue with duplicated location logs. Applying the database migrations from this fix will clear the existing migration logs. [#1836](https://github.com/spotify/backstage/pull/1836) +- Fixed an issue with duplicated location logs. Applying the database migrations from this fix will clear the existing migration logs. [#1836](https://github.com/backstage/backstage/pull/1836) ### @backstage/auth-backend This version fixes a breakage in CSP policies set by the auth backend. If you're facing trouble with auth in alpha.17, upgrade to alpha.18. -- OAuth redirect URLs no longer receive the `env` parameter, as it is now passed through state instead. This will likely require a reconfiguration of the OAuth app, where a redirect URL like `http://localhost:7000/auth/google/handler/frame?env=development` should now be configured as `http://localhost:7000/auth/google/handler/frame`. [#1812](https://github.com/spotify/backstage/pull/1812) +- OAuth redirect URLs no longer receive the `env` parameter, as it is now passed through state instead. This will likely require a reconfiguration of the OAuth app, where a redirect URL like `http://localhost:7000/auth/google/handler/frame?env=development` should now be configured as `http://localhost:7000/auth/google/handler/frame`. [#1812](https://github.com/backstage/backstage/pull/1812) ### @backstage/core -- `SignInPage` props have been changed to receive a list of provider objects instead of simple string identifiers for all but the `'guest'` and `'custom'` providers. This opens up for configuration of custom providers, but may break existing configurations. See [packages/app/src/App.tsx](https://github.com/spotify/backstage/blob/032ba401af36a760efdac41668d7000ccf09bc57/packages/app/src/App.tsx#L36) and [packages/app/src/identityProviders.ts](https://github.com/spotify/backstage/blob/032ba401af36a760efdac41668d7000ccf09bc57/packages/app/src/identityProviders.ts#L24) for how to bring back the existing providers. [#1816](https://github.com/spotify/backstage/pull/1816) +- `SignInPage` props have been changed to receive a list of provider objects instead of simple string identifiers for all but the `'guest'` and `'custom'` providers. This opens up for configuration of custom providers, but may break existing configurations. See [packages/app/src/App.tsx](https://github.com/backstage/backstage/blob/032ba401af36a760efdac41668d7000ccf09bc57/packages/app/src/App.tsx#L36) and [packages/app/src/identityProviders.ts](https://github.com/backstage/backstage/blob/032ba401af36a760efdac41668d7000ccf09bc57/packages/app/src/identityProviders.ts#L24) for how to bring back the existing providers. [#1816](https://github.com/backstage/backstage/pull/1816) ## v0.1.1-alpha.17 ### @backstage/techdocs-backend -- The techdocs backend now requires more configuration to be supplied when creating the router. See [packages/backend/src/plugins/techdocs.ts](https://github.com/spotify/backstage/blob/0201fd9b4a52429519dd59e9184106ba69456deb/packages/backend/src/plugins/techdocs.ts#L42) for an example. [#1736](https://github.com/spotify/backstage/pull/1736) +- The techdocs backend now requires more configuration to be supplied when creating the router. See [packages/backend/src/plugins/techdocs.ts](https://github.com/backstage/backstage/blob/0201fd9b4a52429519dd59e9184106ba69456deb/packages/backend/src/plugins/techdocs.ts#L42) for an example. [#1736](https://github.com/backstage/backstage/pull/1736) ### @backstage/cli -- The `create-app` command was moved out from the CLI to a standalone package. It's now invoked with `npx @backstage/create-app` instead. [#1745](https://github.com/spotify/backstage/pull/1745) +- The `create-app` command was moved out from the CLI to a standalone package. It's now invoked with `npx @backstage/create-app` instead. [#1745](https://github.com/backstage/backstage/pull/1745) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85834076d4..b7157d9f05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,11 +12,11 @@ Backstage is released under the Apache2.0 License, and original creations contri ## Report bugs -No one likes bugs. Report bugs as an issue [here](https://github.com/spotify/backstage/issues/new?template=bug_template.md). +No one likes bugs. Report bugs as an issue [here](https://github.com/backstage/backstage/issues/new?template=bug_template.md). ## Fix bugs or build new features -Look through the GitHub issues for [bugs](https://github.com/spotify/backstage/labels/bugs), [good first issues](https://github.com/spotify/backstage/labels/good%20first%20issue) or [help wanted](https://github.com/spotify/backstage/labels/help%20wanted). +Look through the GitHub issues for [bugs](https://github.com/backstage/backstage/labels/bugs), [good first issues](https://github.com/backstage/backstage/labels/good%20first%20issue) or [help wanted](https://github.com/backstage/backstage/labels/help%20wanted). ## Build a plugin @@ -24,17 +24,17 @@ The value of Backstage grows with every new plugin that gets added. Wouldn't it A great reference example of a plugin can be found on [our blog](https://backstage.io/blog/2020/04/06/lighthouse-plugin) (thanks [@fastfrwrd](https://github.com/fastfrwrd)!) -What kind of plugins should/could be created? Some inspiration from the 120+ plugins that we have developed inside Spotify can be found [here](https://backstage.io/demos), but we will keep a running list of suggestions labeled with [[plugin]](https://github.com/spotify/backstage/labels/plugin). +What kind of plugins should/could be created? Some inspiration from the 120+ plugins that we have developed inside Spotify can be found [here](https://backstage.io/demos), but we will keep a running list of suggestions labeled with [[plugin]](https://github.com/backstage/backstage/labels/plugin). ## Suggesting a plugin -If you start developing a plugin that you aim to release as open source, we suggest that you create a [new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. +If you start developing a plugin that you aim to release as open source, we suggest that you create a [new Issue](https://github.com/backstage/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope that someone else will pick up the work. ## Adding Non-code Contributions -Since there is such a large landscape of possible development, build, and deployment environments, we welcome community contributions in these areas in the [`/contrib`](https://github.com/spotify/backstage/tree/master/contrib) folder of the project. This is an excellent place to put things that help out the community at large, but which may not fit within the scope of the core product to support natively. Here, you will find Helm charts, alternative Docker images, and much more. +Since there is such a large landscape of possible development, build, and deployment environments, we welcome community contributions in these areas in the [`/contrib`](https://github.com/backstage/backstage/tree/master/contrib) folder of the project. This is an excellent place to put things that help out the community at large, but which may not fit within the scope of the core product to support natively. Here, you will find Helm charts, alternative Docker images, and much more. ## Write Documentation @@ -44,11 +44,11 @@ The current documentation is very limited. Help us make the `/docs` folder come We think the best way to ensure different plugins provide a consistent experience is through a solid set of reusable UI/UX components. Backstage uses [Storybook](http://backstage.io/storybook). -Either help us [create new components](https://github.com/spotify/backstage/labels/help%20wanted) or improve stories for the existing ones (look for files with `*.stories.tsx`). +Either help us [create new components](https://github.com/backstage/backstage/labels/help%20wanted) or improve stories for the existing ones (look for files with `*.stories.tsx`). ## Submit Feedback -The best way to send feedback is to file [an issue](https://github.com/spotify/backstage/issues). +The best way to send feedback is to file [an issue](https://github.com/backstage/backstage/issues). If you are proposing a feature: @@ -74,7 +74,7 @@ All code is formatted with `prettier` using the configuration in the repo. If po If you're contributing to the backend or CLI tooling, be mindful of cross-platform support. [This](https://shapeshed.com/writing-cross-platform-node/) blog post is a good guide of what to keep in mind when writing cross-platform NodeJS. -Also be sure to skim through our [ADRs](https://github.com/spotify/backstage/tree/master/docs/architecture-decisions) to see if they cover what you're working on. In particular [ADR006: Avoid React.FC and React.SFC](https://github.com/spotify/backstage/blob/master/docs/architecture-decisions/adr006-avoid-react-fc.md) is one to look out for. +Also be sure to skim through our [ADRs](https://github.com/backstage/backstage/tree/master/docs/architecture-decisions) to see if they cover what you're working on. In particular [ADR006: Avoid React.FC and React.SFC](https://github.com/backstage/backstage/blob/master/docs/architecture-decisions/adr006-avoid-react-fc.md) is one to look out for. If there are any updates in `markdown` file please make sure to run `yarn run lint:docs`. Though it is checked on `lint-staged`. It is required to install [vale](https://docs.errata.ai/vale/install) separately and make sure it is accessed by global command. @@ -87,7 +87,7 @@ We use [changesets](https://github.com/atlassian/changesets) to help us prepare 1. Run `yarn changeset` 2. Select which packages you want to include a changeset for 3. Select impact of change that you're introducing (minor, major or patch) -4. Add generated changset to Git +4. Add generated changeset to Git 5. Push the commit with your changeset to the branch associated with your PR 6. Accept our gratitude for making the release process easier on the maintainer @@ -97,7 +97,7 @@ For more information, checkout [adding a changeset](https://github.com/atlassian This project adheres to the [Spotify FOSS Code of Conduct][code-of-conduct]. By participating, you are expected to honor this code. -[code-of-conduct]: https://github.com/spotify/backstage/blob/master/CODE_OF_CONDUCT.md +[code-of-conduct]: https://github.com/backstage/backstage/blob/master/CODE_OF_CONDUCT.md # Security Issues? diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index 1aebbeb0eb..0000000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -1,27 +0,0 @@ -# Deploying Backstage - -## Heroku - -Deploying to heroku is relatively easy following these steps. - -First, make sure you have the [heroku CLI installed](https://devcenter.heroku.com/articles/heroku-cli) and log into it as well as login into Heroku's [container registry](https://devcenter.heroku.com/articles/container-registry-and-runtime). - -```bash -$ heroku login -$ heroku container:login -``` - -You _might_ also need to set your Heroku app's stack to `container` - -```bash -$ heroku stack:set container -a -``` - -We can now build/push the Docker image to Heroku's container registry and release it to the `web` worker. - -```bash -$ heroku container:push web -a -$ heroku container:release web -a -``` - -With that, you should have Backstage up and running! diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000000..65841d1c6a --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,45 @@ +# Process for becoming a maintainer + +## Your organization is not yet a maintainer + +- Express interest to the sponsors that your organization is interested in becoming a maintainer. Becoming a maintainer generally means that you are going to be spending substantial time on Backstage for the foreseeable future. You should have domain expertise and be extremely proficient in TypeScript. +- We will expect you to start contributing increasingly complicated PRs, under the guidance of the existing maintainers. +- We may ask you to do some PRs from our backlog. +- As you gain experience with the code base and our standards, we will ask you to do code reviews for incoming PRs. +- After a period of approximately 2-3 months of working together and making sure we see eye to eye, the existing sponsors and maintainers will confer and decide whether to grant maintainer status or not. We make no guarantees on the length of time this will take, but 2-3 months is the approximate goal. + +## Your organization is currently a maintainer + +To become a maintainer you need to demonstrate the following: + +- First decide whether your organization really needs more people with maintainer access. Valid reasons are "blast radius", a large organization that is working on multiple unrelated projects, etc. +- Contact a sponsor for your organization and express interest. +- Start doing PRs and code reviews under the guidance of your maintainer. +- After a period of 1-2 months the existing sponsors will discuss granting maintainer access. +- Maintainer access can be upgraded to sponsor access after another conference of the existing sponsors. + +# Maintainer responsibilities + +- Monitor email aliases. +- Monitor Discord (delayed response is perfectly acceptable). +- Triage GitHub issues and perform pull request reviews for other maintainers and the community. +- Triage build issues - file issues for known flaky builds or bugs, and either fix or find someone to fix any master build breakages. +- During GitHub issue triage, apply all applicable ([labels](https://github.com/backstage/backstage/labels)) to each new issue. Labels are extremely useful for future issue follow up. Which labels to apply is somewhat subjective so just use your best judgment. A few of the most important labels that are not self explanatory are: + - good first issue: Mark any issue that can reasonably be accomplished by a new contributor with this label. + - help wanted: Unless it is immediately obvious that someone is going to work on an issue (and if so assign it), mark it help wanted. +- Make sure that ongoing PRs are moving forward at the right pace or closing them. +- Participate when called upon in the security release process. Note that although this should be a rare occurrence, if a serious vulnerability is found, the process may take up to several full days of work to implement. This reality should be taken into account when discussing time commitment obligations with employers. +- In general, continue to be willing to spend at least 25% of one's time working on Backstage (~1.25 business days per week). +- We currently maintain an "on-call" rotation within the maintainers. Each on-call is 1 week. Although all maintainers are welcome to perform all of the above tasks, it is the on-call maintainer's responsibility to triage incoming issues/questions and marshal ongoing work forward. To reiterate, it is not the responsibility of the on-call maintainer to answer all questions and do all reviews, but it is their responsibility to make sure that everything is being actively covered by someone. + +# When does a maintainer lose maintainer status + +If a maintainer is no longer interested or cannot perform the maintainer duties listed above, they should volunteer to be moved to emeritus status. In extreme cases this can also occur by a vote of the sponsors and maintainers per the voting process below. + +# Conflict resolution and voting + +In general, we prefer that technical issues and maintainer membership are amicably worked out between the persons involved. If a dispute cannot be decided independently, the sponsors and maintainers can be called in to decide an issue. If the sponsors and maintainers themselves cannot decide an issue, the issue will be resolved by voting. The voting process is a simple majority in which each sponsor receives two votes and each maintainer receives one vote. + +# Adding new projects to the Backstage GitHub organization + +New projects will be added to the Backstage organization via GitHub issue discussion in one of the existing projects in the organization. Once sufficient discussion has taken place (~3-5 business days but depending on the volume of conversation), the maintainers of the project where the issue was opened (since different projects in the organization may have different maintainers) will decide whether the new project should be added. See the section above on voting if the maintainers cannot easily decide. diff --git a/OWNERS.md b/OWNERS.md index 1e7fe5b774..8e5002fc0a 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -1,19 +1,24 @@ -# Owners - - See [CONTRIBUTING.md](CONTRIBUTING.md) for general contribution guidelines. +- See [GOVERNANCE.md](GOVERNANCE.md) for governance guidelines and responsibilities. -## Maintainers 🏓 +This page lists all active sponsors and maintainers. -- Patrik Oldsberg (@Rugvip, Spotify) -- Fredrik Adelöw (@freben, Spotify) -- Raghunandan Balachandran (@soapraj, Spotify) -- Ben Lambert (@benjdlambert, Spotify) -- Marcus Eide (@marcuseide, Spotify) -- Niklas Ek (@nikek, Spotify) -- Stefan Ålund (@stefanalund, Spotify) -- Kat Zhou (@katz95, Spotify) +# Sponsors -## Hall of Fame 👏 +- Niklas Gustavsson ([protocol7](https://github.com/protocol7)) (ngn@spotify.com) +- Dave Zolotusky ([dzolotusky](https://github.com/dzolotusky)) (dzolo@spotify.com) +- Lee Mills ([leemills83](https://github.com/leemills83)) (leem@spotify.com) -- Andrew Thauer (@andrewthauer, Wealthsimple) -- Oliver Sand (@Fox32, SDA-SE) +# Maintainers + +- Patrik Oldsberg ([rugvip](https://github.com/rugvip)) (Discord: @Rugvip) +- Fredrik Adelöw ([freben](https://github.com/freben)) (Discord: @freben) +- Ben Lambert ([benjdlambert](https://github.com/benjdlambert)) (Discord: @blam) +- Stefan Ålund ([stefanalund](https://github.com/stefanalund)) (Discord: @stalund) + +# Friends of Backstage + +People that have made significant contributions to the project and earned write access. + +- Andrew Thauer - Wealthsimple (GitHub: [andrewthauer](https://github.com/andrewthauer)) +- Oliver Sand - SDA SE (GitHub: [Fox32](https://github.com/Fox32)) diff --git a/README.md b/README.md index e05c1eb16e..a4b21539b5 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,16 @@ # [Backstage](https://backstage.io) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -![](https://github.com/spotify/backstage/workflows/Frontend%20CI/badge.svg) +[![CNCF Status](https://img.shields.io/badge/cncf%20status-sandbox-blue.svg)](https://www.cncf.io/projects) +[![Main CI Build](https://github.com/backstage/backstage/workflows/Main%20Master%20Build/badge.svg)](https://github.com/backstage/backstage/actions?query=workflow%3A%22Main+Master+Build%22) [![Discord](https://img.shields.io/discord/687207715902193673)](https://discord.gg/EBHEGzX) ![Code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg) -[![Codecov](https://img.shields.io/codecov/c/github/spotify/backstage)](https://codecov.io/gh/spotify/backstage) -[![](https://img.shields.io/npm/v/@backstage/core?label=Version)](https://github.com/spotify/backstage/releases) +[![Codecov](https://img.shields.io/codecov/c/github/backstage/backstage)](https://codecov.io/gh/backstage/backstage) +[![](https://img.shields.io/npm/v/@backstage/core?label=Version)](https://github.com/backstage/backstage/releases) + +> We recently moved from `spotify/backstage`, update your remotes! +> +> `git remote set-url origin git@github.com:backstage/backstage.git` ## What is Backstage? @@ -22,11 +27,9 @@ Out of the box, Backstage includes: - [Backstage Service Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview) for managing all your software (microservices, libraries, data pipelines, websites, ML models, etc.) - [Backstage Software Templates](https://backstage.io/docs/features/software-templates/software-templates-index) for quickly spinning up new projects and standardizing your tooling with your organization’s best practices - [Backstage TechDocs](https://backstage.io/docs/features/techdocs/techdocs-overview) for making it easy to create, maintain, find, and use technical documentation, using a "docs like code" approach -- Plus, a growing ecosystem of [open source plugins](https://github.com/spotify/backstage/tree/master/plugins) that further expand Backstage’s customizability and functionality +- Plus, a growing ecosystem of [open source plugins](https://github.com/backstage/backstage/tree/master/plugins) that further expand Backstage’s customizability and functionality -For more information go to [backstage.io](https://backstage.io) or join our [Discord chatroom](https://discord.gg/EBHEGzX). - -🎉 Backstage is a CNCF Sandbox project. Read the announcement [here](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox). +Backstage was created by Spotify but is now hosted by the [Cloud Native Computing Foundation (CNCF)](https://www.cncf.io) as a Sandbox level project. Read the announcement [here](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox). ## Project roadmap @@ -47,8 +50,8 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how ## Community - [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the project -- [Good First Issues](https://github.com/spotify/backstage/contribute) - Start here if you want to contribute -- [RFCs](https://github.com/spotify/backstage/labels/rfc) - Help shape the technical direction +- [Good First Issues](https://github.com/backstage/backstage/contribute) - Start here if you want to contribute +- [RFCs](https://github.com/backstage/backstage/labels/rfc) - Help shape the technical direction - [FAQ](https://backstage.io/docs/FAQ) - Frequently Asked Questions - [Code of Conduct](CODE_OF_CONDUCT.md) - This is how we roll - [Adopters](ADOPTERS.md) - Companies already using Backstage @@ -58,6 +61,6 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how ## License -Copyright 2020 Spotify AB. +Copyright 2020 © Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 diff --git a/app-config.development.yaml b/app-config.development.yaml deleted file mode 100644 index 817847c6d6..0000000000 --- a/app-config.development.yaml +++ /dev/null @@ -1,13 +0,0 @@ -app: - baseUrl: http://localhost:3000 - -backend: - baseUrl: http://localhost:7000 - listen: - port: 7000 - cors: - origin: http://localhost:3000 - methods: [GET, POST, PUT, DELETE] - credentials: true - csp: - connect-src: ["'self'", 'http:', 'https:'] diff --git a/app-config.yaml b/app-config.yaml index d670771a31..a331d72d7c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -1,6 +1,7 @@ app: title: Backstage Example App - baseUrl: http://localhost:7000 + baseUrl: http://localhost:3000 + googleAnalyticsTrackingId: # UA-000000-0 backend: baseUrl: http://localhost:7000 @@ -9,8 +10,13 @@ backend: database: client: sqlite3 connection: ':memory:' + cors: + origin: http://localhost:3000 + methods: [GET, POST, PUT, DELETE] + credentials: true csp: - connect-src: ["'self'", 'https:'] + connect-src: ["'self'", 'http:', 'https:'] + # workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir # See README.md in the proxy-backend plugin for information on the configuration format proxy: @@ -34,8 +40,20 @@ proxy: $env: TRAVISCI_AUTH_TOKEN travis-api-version: 3 + '/newrelic/apm/api': + target: https://api.newrelic.com/v2 + headers: + X-Api-Key: + $env: NEW_RELIC_REST_API_KEY + + '/buildkite/api': + target: https://api.buildkite.com/v2/ + headers: + Authorization: + $env: BUILDKITE_TOKEN + organization: - name: Spotify + name: My Company techdocs: storageUrl: http://localhost:7000/api/techdocs/static/docs @@ -44,44 +62,41 @@ techdocs: techdocs: 'docker' sentry: - organization: spotify + organization: my-company rollbar: - organization: spotify + organization: my-company accountToken: $env: ROLLBAR_ACCOUNT_TOKEN -newrelic: - api: - baseUrl: 'https://api.newrelic.com/v2' - key: NEW_RELIC_REST_API_KEY - lighthouse: baseUrl: http://localhost:3003 kubernetes: - clusterLocatorMethod: 'configMultiTenant' + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' clusters: [] integrations: github: - host: github.com token: - $env: GITHUB_PRIVATE_TOKEN + $env: GITHUB_TOKEN ### Example for how to add your GitHub Enterprise instance using the API: # - host: ghe.example.net # apiBaseUrl: https://ghe.example.net/api/v3 # token: - # $env: GHE_PRIVATE_TOKEN + # $env: GHE_TOKEN ### Example for how to add your GitHub Enterprise instance using raw HTTP fetches (token is optional): # - host: ghe.example.net # rawBaseUrl: https://ghe.example.net/raw # token: - # $env: GHE_PRIVATE_TOKEN + # $env: GHE_TOKEN gitlab: - host: gitlab.com token: - $env: GITLAB_PRIVATE_TOKEN + $env: GITLAB_TOKEN bitbucket: - host: bitbucket.org username: @@ -91,7 +106,7 @@ integrations: azure: - host: dev.azure.com token: - $env: AZURE_PRIVATE_TOKEN + $env: AZURE_TOKEN catalog: rules: @@ -102,12 +117,12 @@ catalog: providers: - target: https://github.com token: - $env: GITHUB_PRIVATE_TOKEN + $env: GITHUB_TOKEN #### Example for how to add your GitHub Enterprise instance using the API: # - target: https://ghe.example.net # apiBaseUrl: https://ghe.example.net/api/v3 # token: - # $env: GHE_PRIVATE_TOKEN + # $env: GHE_TOKEN ldapOrg: ### Example for how to add your enterprise LDAP server # providers: @@ -129,35 +144,38 @@ catalog: locations: # Backstage example components - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-components.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-components.yaml # Example component for github-actions - type: url - target: https://github.com/spotify/backstage/blob/master/plugins/github-actions/examples/sample.yaml + target: https://github.com/backstage/backstage/blob/master/plugins/github-actions/examples/sample.yaml # Example component for techdocs - type: url - target: https://github.com/spotify/backstage/blob/master/plugins/techdocs-backend/examples/documented-component/documented-component.yaml + target: https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component/documented-component.yaml # Backstage example APIs - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml # Backstage example templates - type: url - target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/all-templates.yaml + target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/all-templates.yaml + # Backstage example groups and users + - type: url + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme-corp.yaml scaffolder: github: token: - $env: GITHUB_ACCESS_TOKEN + $env: GITHUB_TOKEN visibility: public # or 'internal' or 'private' gitlab: api: baseUrl: https://gitlab.com token: - $env: GITLAB_ACCESS_TOKEN + $env: GITLAB_TOKEN azure: baseUrl: https://dev.azure.com/{your-organization} api: token: - $env: AZURE_PRIVATE_TOKEN + $env: AZURE_TOKEN auth: providers: @@ -220,6 +238,14 @@ auth: $env: AUTH_MICROSOFT_CLIENT_SECRET tenantId: $env: AUTH_MICROSOFT_TENANT_ID + onelogin: + development: + clientId: + $env: AUTH_ONELOGIN_CLIENT_ID + clientSecret: + $env: AUTH_ONELOGIN_CLIENT_SECRET + issuer: + $env: AUTH_ONELOGIN_ISSUER costInsights: engineerCost: 200000 products: @@ -233,17 +259,18 @@ costInsights: name: Cloud Storage icon: storage bigQuery: - name: Big Query + name: BigQuery icon: search metrics: - dailyCost: - name: Your Company's Daily Cost DAU: - name: Cost Per DAU + name: Daily Active Users + default: true + MSC: + name: Monthly Subscribers homepage: clocks: - label: UTC - timzone: UTC + timezone: UTC - label: NYC timezone: 'America/New_York' - label: STO diff --git a/catalog-info.yaml b/catalog-info.yaml index 937a55bfca..617d01093e 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -5,9 +5,10 @@ metadata: description: | Backstage is an open-source developer portal that puts the developer experience first. annotations: - github.com/project-slug: spotify/backstage - backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git + github.com/project-slug: backstage/backstage + backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + lighthouse.com/website-url: https://backstage.io spec: type: library - owner: Spotify + owner: CNCF lifecycle: experimental diff --git a/contrib/chart/backstage/Chart.lock b/contrib/chart/backstage/Chart.lock new file mode 100644 index 0000000000..e390bf21ba --- /dev/null +++ b/contrib/chart/backstage/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 9.4.1 +digest: sha256:f949ec0fe7d146610ce2ee78fbfb4e52d235883a797968b0a1e61aa88ada2786 +generated: "2020-09-25T08:59:54.255582519+02:00" diff --git a/contrib/chart/backstage/Chart.yaml b/contrib/chart/backstage/Chart.yaml new file mode 100644 index 0000000000..8aeab9be3e --- /dev/null +++ b/contrib/chart/backstage/Chart.yaml @@ -0,0 +1,30 @@ +apiVersion: v2 +name: backstage +description: A Helm chart for Backstage +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.1 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. +appVersion: v0.1.1-alpha.23 + +sources: + - https://github.com/backstage/backstage + - https://github.com/spotify/lighthouse-audit-service + +dependencies: + - name: postgresql + condition: postgresql.enabled + version: 9.4.1 + repository: https://charts.bitnami.com/bitnami + +maintainers: + - name: Martina Iglesias Fernández + email: martina@roadie.io + url: https://roadie.io + - name: David Tuite + email: david@roadie.io + url: https://roadie.io diff --git a/contrib/chart/backstage/README.md b/contrib/chart/backstage/README.md new file mode 100644 index 0000000000..96bfdb4351 --- /dev/null +++ b/contrib/chart/backstage/README.md @@ -0,0 +1,251 @@ +# Backstage demo helm charts + +This folder contains Helm charts that can easily create a Kubernetes deployment of a demo Backstage app. + +### Pre-requisites + +These charts depend on the `nginx-ingress` controller being present in the cluster. If it's not already installed you +can run: + +```shell +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx +helm install nginx-ingress ingress-nginx/ingress-nginx +``` + +### Installing the charts + +After choosing a DNS name where backstage will be hosted create a yaml file for your custom configuration. + +```yaml +appConfig: + app: + baseUrl: https://backstage.mydomain.com + title: Backstage + backend: + baseUrl: https://backstage.mydomain.com + cors: + origin: https://backstage.mydomain.com + lighthouse: + baseUrl: https://backstage.mydomain.com/lighthouse-api + techdocs: + storageUrl: https://backstage.mydomain.com/api/techdocs/static/docs + requestUrl: https://backstage.mydomain.com/api/techdocs +``` + +Then use it to run: + +```shell +git clone https://github.com/backstage/backstage.git +cd contrib/chart/backstage +helm dependency update +helm install -f backstage-mydomain.yaml backstage . +``` + +This command will deploy the following pieces: + +- Backstage frontend +- Backstage backend with scaffolder and auth plugins +- (optional) a PostgreSQL instance +- lighthouse plugin +- ingress + +After a few minutes Backstage should be up and running in your cluster under the DNS specified earlier. + +Make sure to create the appropriate DNS entry in your infrastructure. To find the public IP address run: + +```shell +$ kubectl get ingress +NAME HOSTS ADDRESS PORTS AGE +backstage-ingress * 123.1.2.3 80 17m +``` + +> **NOTE**: this is not a production ready deployment. + +## Customization + +### Issue certificates + +These charts can install or reuse a `clusterIssuer` to generate certificates for the backstage `ingress`. To do that: + +1. [Install][install-cert-manager] or make sure [cert-manager][cert-manager] is installed in the cluster. +2. Enable the issuer in the charts. This will first check if there is a `letsencrypt` issuer already deployed in your + cluster and deploy one if it doesn't exist. + +To enable it you need to provide a valid email address in the chart's values: + +```yaml +issuer: + email: me@example.com + clusterIssuer: 'letsencrypt-prod' +``` + +By default, the charts use `letsencrypt-staging` so in the above example we instruct helm to use the production issuer +instead. + +[cert-manager]: https://cert-manager.io/docs/ +[install-cert-manager]: https://cert-manager.io/docs/installation/kubernetes/#installing-with-helm + +### Custom PostgreSQL instance + +Configuring a connection to an existing PostgreSQL instance is possible through the chart's values. + +First create a yaml file with the configuration you want to override, for example `backstage-prod.yaml`: + +```yaml +postgresql: + enabled: false + +appConfig: + app: + baseUrl: https://backstage-demo.mydomain.com + title: Backstage + backend: + baseUrl: https://backstage-demo.mydomain.com + cors: + origin: https://backstage-demo.mydomain.com + database: + client: pg + connection: + database: backstage_plugin_catalog + host: + user: + password: + lighthouse: + baseUrl: https://backstage-demo.mydomain.com/lighthouse-api + +lighthouse: + database: + client: pg + connection: + host: + user: + password: + database: lighthouse_audit_service +``` + +For the CA, create a `configMap` named `--postgres-ca` with a file called `ca.crt`: + +```shell +kubectl create configmap my-company-backstage-postgres-ca --from-file=ca.crt" +``` + +> Where the release name contains the chart name "backstage" then only the release name will be used. + +Now install the helm chart: + +```shell +cd contrib/chart/backstage +helm install -f backstage-prod.yaml my-backstage . +``` + +### Use your own docker images + +The docker images used for the deployment can be configured through the charts values: + +```yaml +frontend: + image: + repository: + tag: + +backend: + image: + repository: + tag: + +lighthouse: + image: + repository: + tag: +``` + +### Use a private docker repo + +Create a docker-registry secret + +```shell +kubectl create secret docker-registry # args +``` + +> For private images on docker hub --docker-server can be set to docker.io + +Reference the secret in your chart values + +```yaml +dockerRegistrySecretName: +``` + +### Different namespace + +To install the charts a specific namespace use `--namespace `: + +```shell +helm install -f my_values.yaml --namespace demos backstage . +``` + +### Disable loading of demo data + +To deploy backstage with the pre-loaded demo data disable `backend.demoData`: + +```shell +helm install -f my_values.yaml --set backend.demoData=false backstage . +``` + +### Other options + +For more customization options take a look at the [values.yaml](/contrib/chart/backstage/values.yaml) file. + +## Troubleshooting + +Some resources created by these charts are meant to survive after upgrades and even after uninstalls. When +troubleshooting these charts it can be useful to delete these resources between re-installs. + +Secrets: + +``` +-postgresql-certs -- contains the certificates used by the deployed PostgreSQL +``` + +Persistent volumes: + +``` +data--postgresql-0 -- this is the data volume used by PostgreSQL to store data and configuration +``` + +> **NOTE**: this volume also stores the configuration for PostgreSQL which includes things like the password for the +> `postgres` user. This means that uninstalling and re-installing the charts with `postgres.enabled` set to `true` and +> auto generated passwords will fail. The solution is to delete this volume with +> `kubectl delete pvc data--postgresql-0` + +ConfigMaps: + +``` +-postgres-ca -- contains the generated CA certificate for PostgreSQL when `postgres` is enabled +``` + +#### Unable to verify signature + +``` +Backend failed to start up Error: unable to verify the first certificate + at TLSSocket.onConnectSecure (_tls_wrap.js:1501:34) + at TLSSocket.emit (events.js:315:20) + at TLSSocket._finishInit (_tls_wrap.js:936:8) + at TLSWrap.ssl.onhandshakedone (_tls_wrap.js:710:12) { + code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' +``` + +This error happens in the backend when it tries to connect to the configured PostgreSQL database and the specified CA is not correct. The solution is to make sure that the contents of the `configMap` that holds the certificate match the CA for the PostgreSQL instance. A workaround is to set `appConfig.backend.database.connection.ssl.rejectUnauthorized` to `false` in the chart's values. + + + +## Uninstalling Backstage + +To uninstall Backstage simply run: + +```shell +RELEASE_NAME= # use `helm list` to find out the name +helm uninstall ${RELEASE_NAME} +kubectl delete pvc data-${RELEASE_NAME}-postgresql-0 +kubectl delete secret ${RELEASE_NAME}-postgresql-certs +kubectl delete configMap ${RELEASE_NAME}-postgres-ca +``` diff --git a/contrib/chart/backstage/files/app-config.development.yaml.tpl b/contrib/chart/backstage/files/app-config.development.yaml.tpl new file mode 100644 index 0000000000..1e0a41ad63 --- /dev/null +++ b/contrib/chart/backstage/files/app-config.development.yaml.tpl @@ -0,0 +1,51 @@ +backend: + lighthouseHostname: {{ include "lighthouse.serviceName" . | quote }} + listen: + port: {{ .Values.appConfig.backend.listen.port | default 7000 }} + database: + client: {{ .Values.appConfig.backend.database.client | quote }} + connection: + host: {{ include "backend.postgresql.host" . | quote }} + port: {{ include "backend.postgresql.port" . | quote }} + user: {{ include "backend.postgresql.user" . | quote }} + database: {{ .Values.appConfig.backend.database.connection.database | quote }} + ssl: + rejectUnauthorized: {{ .Values.appConfig.backend.database.connection.ssl.rejectUnauthorized | quote }} + ca: {{ include "backstage.backend.postgresCaFilename" . | quote }} + +catalog: +{{- if .Values.backend.demoData }} + locations: + # Backstage example components + - type: github + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-components.yaml + # Example component for github-actions + - type: github + target: https://github.com/backstage/backstage/blob/master/plugins/github-actions/examples/sample.yaml + # Example component for techdocs + - type: github + target: https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component/documented-component.yaml + # Backstage example APIs + - type: github + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml + # Backstage example templates + - type: github + target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/all-templates.yaml +{{- else }} + locations: [] +{{- end }} + +auth: + providers: + microsoft: null + +scaffolder: + azure: null + + +sentry: + organization: {{ .Values.appConfig.sentry.organization | quote }} + +techdocs: + generators: + techdocs: 'local' diff --git a/contrib/chart/backstage/files/create-backend-dbs.sql b/contrib/chart/backstage/files/create-backend-dbs.sql new file mode 100644 index 0000000000..043ff7daf5 --- /dev/null +++ b/contrib/chart/backstage/files/create-backend-dbs.sql @@ -0,0 +1,13 @@ +{{ $backendDb := .Values.appConfig.backend.database.connection.database }} +{{ $lighthouseDb := .Values.lighthouse.database.connection.database }} +{{ $user := .Values.global.postgresql.postgresqlUsername }} + +grant all privileges on database {{ $backendDb }} to {{ $user }}; + +create database backstage_plugin_auth; +grant all privileges on database backstage_plugin_auth to {{ $user }}; + +{{ if not (eq $backendDb $lighthouseDb) }} +create database {{ $lighthouseDb }}; +grant all privileges on database {{ $lighthouseDb }} to {{ $user }}; +{{ end }} diff --git a/contrib/chart/backstage/templates/_helpers.tpl b/contrib/chart/backstage/templates/_helpers.tpl new file mode 100644 index 0000000000..5d49ba6975 --- /dev/null +++ b/contrib/chart/backstage/templates/_helpers.tpl @@ -0,0 +1,286 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "backstage.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "backstage.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "backstage.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common App labels +*/}} +{{- define "backstage.app.labels" -}} +app.kubernetes.io/name: {{ include "backstage.name" . }}-app +helm.sh/chart: {{ include "backstage.chart" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Common Backend labels +*/}} +{{- define "backstage.backend.labels" -}} +app.kubernetes.io/name: {{ include "backstage.name" . }}-backend +helm.sh/chart: {{ include "backstage.chart" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Name for postgresql dependency +See https://github.com/helm/helm/issues/3920#issuecomment-686913512 +*/}} +{{- define "backstage.postgresql.fullname" -}} +{{ printf "%s-%s" .Release.Name .Values.postgresql.nameOverride }} +{{- end -}} + + +{{/* +Create the name of the service account to use for the app +*/}} +{{- define "backstage.app.serviceAccountName" -}} +{{- if .Values.app.serviceAccount.create -}} + {{ default "default" .Values.app.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.app.serviceAccount.name }} +{{- end -}} +{{- end -}} + +{{/* +Create the name of the service account to use for the backend +*/}} +{{- define "backstage.backend.serviceAccountName" -}} +{{- if .Values.backend.serviceAccount.create -}} + {{ default default .Values.backend.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.backend.serviceAccount.name }} +{{- end -}} +{{- end -}} + +{{/* +Path to the CA certificate file in the backend +*/}} +{{- define "backstage.backend.postgresCaFilename" -}} +{{ include "backstage.backend.postgresCaDir" . }}/{{- required "The name for the CA certificate file for postgresql is required" .Values.global.postgresql.caFilename }} +{{- end -}} +{{/* + +{{/* +Directory path to the CA certificate file in the backend +*/}} +{{- define "backstage.backend.postgresCaDir" -}} +{{- if .Values.appConfig.backend.database.connection.ssl.ca -}} + {{ .Values.appConfig.backend.database.connection.ssl.ca }} +{{- else -}} +/etc/postgresql +{{- end -}} +{{- end -}} +{{/* + +Path to the CA certificate file in lighthouse +*/}} +{{- define "backstage.lighthouse.postgresCaFilename" -}} +{{ include "backstage.lighthouse.postgresCaDir" . }}/{{- required "The name for the CA certificate file for postgresql is required" .Values.global.postgresql.caFilename }} +{{- end -}} + +{{/* +Directory path to the CA certificate file in lighthouse +*/}} +{{- define "backstage.lighthouse.postgresCaDir" -}} +{{- if .Values.lighthouse.database.pathToDatabaseCa -}} + {{ .Values.lighthouse.database.pathToDatabaseCa }} +{{- else -}} +/etc/postgresql +{{- end -}} +{{- end -}} +{{/* + +{{/* +Generate ca for postgresql +*/}} +{{- define "backstage.postgresql.generateCA" -}} +{{- $ca := .ca | default (genCA (include "backstage.postgresql.fullname" .) 365) -}} +{{- $_ := set . "ca" $ca -}} +{{- $ca.Cert -}} +{{- end -}} + +{{/* +Generate certificates for postgresql +*/}} +{{- define "generateCerts" -}} +{{- $postgresName := (include "backstage.postgresql.fullname" .) }} +{{- $altNames := list $postgresName ( printf "%s.%s" $postgresName .Release.Namespace ) ( printf "%s.%s.svc" ( $postgresName ) .Release.Namespace ) -}} +{{- $ca := .ca | default (genCA (include "backstage.postgresql.fullname" .) 365) -}} +{{- $_ := set . "ca" $ca -}} +{{- $cert := genSignedCert ( $postgresName ) nil $altNames 365 $ca -}} +tls.crt: {{ $cert.Cert | b64enc }} +tls.key: {{ $cert.Key | b64enc }} +{{- end -}} + +{{/* +Generate a password for the postgres user used for the connections from the backend and lighthouse +*/}} +{{- define "postgresql.generateUserPassword" -}} +{{- $pgPassword := .pgPassword | default ( randAlphaNum 12 ) -}} +{{- $_ := set . "pgPassword" $pgPassword -}} +{{ $pgPassword}} +{{- end -}} + +{{/* +Name of the backend service +*/}} +{{- define "backend.serviceName" -}} +{{ include "backstage.fullname" . }}-backend +{{- end -}} + +{{/* +Name of the frontend service +*/}} +{{- define "frontend.serviceName" -}} +{{ include "backstage.fullname" . }}-frontend +{{- end -}} + +{{/* +Name of the lighthouse backend service +*/}} +{{- define "lighthouse.serviceName" -}} +{{ include "backstage.fullname" . }}-lighthouse +{{- end -}} + +{{/* +Name of the postgresql service +*/}} +{{- define "postgresql.serviceName" -}} +{{- include "backstage.postgresql.fullname" . }} +{{- end -}} + +{{/* +Postgres host for lighthouse +*/}} +{{- define "lighthouse.postgresql.host" -}} +{{- if .Values.postgresql.enabled }} +{{- include "postgresql.serviceName" . }} +{{- else -}} +{{- required "A valid .Values.lighthouse.database.connection.host is required when postgresql is not enabled" .Values.lighthouse.database.connection.host -}} +{{- end -}} +{{- end -}} + +{{/* +Postgres host for the backend +*/}} +{{- define "backend.postgresql.host" -}} +{{- if .Values.postgresql.enabled }} +{{- include "postgresql.serviceName" . }} +{{- else -}} +{{- required "A valid .Values.appConfig.backend.database.connection.host is required when postgresql is not enabled" .Values.appConfig.backend.database.connection.host -}} +{{- end -}} +{{- end -}} + +{{/* +Postgres port for the backend +*/}} +{{- define "backend.postgresql.port" -}} +{{- if .Values.postgresql.enabled }} +{{- .Values.postgresql.service.port }} +{{- else if .Values.appConfig.backend.database.connection.port -}} +{{- .Values.appConfig.backend.database.connection.port }} +{{ else }} +5432 +{{- end -}} +{{- end -}} + +{{/* +Postgres port for lighthouse +*/}} +{{- define "lighthouse.postgresql.port" -}} +{{- if .Values.postgresql.enabled }} +{{- .Values.postgresql.service.port }} +{{- else if .Values.lighthouse.database.connection.port -}} +{{- .Values.lighthouse.database.connection.port }} +{{- else -}} +5432 +{{- end -}} +{{- end -}} + +{{/* +Postgres user for backend +*/}} +{{- define "backend.postgresql.user" -}} +{{- if .Values.postgresql.enabled }} +{{- .Values.global.postgresql.postgresqlUsername }} +{{- else -}} +{{- required "A valid .Values.appConfig.backend.database.connection.user is required when postgresql is not enabled" .Values.appConfig.backend.database.connection.user -}} +{{- end -}} +{{- end -}} + +{{/* +Postgres user for lighthouse +*/}} +{{- define "lighthouse.postgresql.user" -}} +{{- if .Values.postgresql.enabled }} +{{- .Values.global.postgresql.postgresqlUsername }} +{{- else -}} +{{- required "A valid .Values.lighthouse.database.connection.user is required when postgresql is not enabled" .Values.lighthouse.database.connection.user -}} +{{- end -}} +{{- end -}} + +{{/* +Postgres password secret for backend +*/}} +{{- define "backend.postgresql.passwordSecret" -}} +{{- if .Values.postgresql.enabled }} +{{- template "backstage.postgresql.fullname" . }} +{{- else -}} +{{ $secretName := (printf "%s-backend-postgres" (include "backstage.fullname" . )) }} +{{- required "A valid .Values.appConfig.backend.database.connection.password is required when postgresql is not enabled" $secretName -}} +{{- end -}} +{{- end -}} + +{{/* +Postgres password for lighthouse +*/}} +{{- define "lighthouse.postgresql.passwordSecret" -}} +{{- if .Values.postgresql.enabled }} +{{- template "backstage.postgresql.fullname" . }} +{{- else -}} +{{ $secretName := (printf "%s-lighthouse-postgres" (include "backstage.fullname" . )) }} +{{- required "A valid .Values.lighthouse.database.connection.password is required when postgresql is not enabled" $secretName -}} +{{- end -}} +{{- end -}} + +{{/* +app-config file name +*/}} +{{- define "backstage.appConfigFilename" -}} +{{- "app-config.development.yaml" -}} +{{- end -}} diff --git a/contrib/chart/backstage/templates/backend-deployment.yaml b/contrib/chart/backstage/templates/backend-deployment.yaml new file mode 100644 index 0000000000..99ec955242 --- /dev/null +++ b/contrib/chart/backstage/templates/backend-deployment.yaml @@ -0,0 +1,83 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "backstage.fullname" . }}-backend + +spec: + replicas: {{ .Values.backend.replicaCount }} + + selector: + matchLabels: + app: backstage + component: backend + + template: + metadata: + annotations: + ad.datadoghq.com/backstage.logs: '[{"source":"backstage","service":"backend"}]' + labels: + app: backstage + component: backend + + spec: + {{- if .Values.dockerRegistrySecretName }} + imagePullSecrets: + - name: {{ .Values.dockerRegistrySecretName }} + {{- end}} + containers: + - name: {{ .Chart.Name }}-backend + image: {{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }} + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - containerPort: {{ .Values.backend.containerPort }} + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + + envFrom: + - secretRef: + name: {{ include "backstage.fullname" . }}-backend + - configMapRef: + name: {{ include "backstage.fullname" . }}-app-env + - configMapRef: + name: {{ include "backstage.fullname" . }}-auth + env: + - name: NODE_ENV + value: {{ .Values.backend.nodeEnv | default "development" }} + - name: APP_CONFIG_backend_database_connection_password + valueFrom: + secretKeyRef: + name: {{ include "backend.postgresql.passwordSecret" .}} + key: postgresql-password + volumeMounts: + - name: postgres-ca + mountPath: {{ include "backstage.backend.postgresCaDir" . }} + - name: app-config + mountPath: {{ printf "/usr/src/app/%s" (include "backstage.appConfigFilename" .) }} + subPath: {{ include "backstage.appConfigFilename" . }} + + volumes: + - name: postgres-ca + configMap: + name: {{ include "backstage.fullname" . }}-postgres-ca + - name: app-config + configMap: + name: {{ include "backstage.fullname" . }}-app-config + +{{- if .Values.backend.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "backend.serviceName" . }} + +spec: + ports: + - port: 80 + targetPort: {{ .Values.backend.containerPort }} + + selector: + app: backstage + component: backend + + type: ClusterIP +{{- end }} diff --git a/contrib/chart/backstage/templates/backend-secret.yaml b/contrib/chart/backstage/templates/backend-secret.yaml new file mode 100644 index 0000000000..b340f39d7c --- /dev/null +++ b/contrib/chart/backstage/templates/backend-secret.yaml @@ -0,0 +1,23 @@ +{{- if .Values.backend.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "backstage.fullname" . }}-backend +type: Opaque +stringData: + AUTH_GOOGLE_CLIENT_SECRET: {{ .Values.auth.google.clientSecret }} + AUTH_GITHUB_CLIENT_SECRET: {{ .Values.auth.github.clientSecret }} + AUTH_GITLAB_CLIENT_SECRET: {{ .Values.auth.gitlab.clientSecret }} + AUTH_OKTA_CLIENT_SECRET: {{ .Values.auth.okta.clientSecret }} + AUTH_OAUTH2_CLIENT_SECRET: {{ .Values.auth.oauth2.clientSecret }} + AUTH_AUTH0_CLIENT_SECRET: {{ .Values.auth.auth0.clientSecret }} + AUTH_MICROSOFT_CLIENT_SECRET: {{ .Values.auth.microsoft.clientSecret }} + SENTRY_TOKEN: {{ .Values.auth.sentryToken }} + ROLLBAR_ACCOUNT_TOKEN: {{ .Values.auth.rollbarAccountToken }} + CIRCLECI_AUTH_TOKEN: {{ .Values.auth.circleciAuthToken }} + GITHUB_TOKEN: {{ .Values.auth.githubToken }} + GITLAB_TOKEN: {{ .Values.auth.gitlabToken }} + AZURE_TOKEN: {{ .Values.auth.azure.api.token }} + NEW_RELIC_REST_API_KEY: {{ .Values.auth.newRelicRestApiKey }} + TRAVISCI_AUTH_TOKEN: {{ .Values.auth.travisciAuthToken }} +{{- end }} diff --git a/contrib/chart/backstage/templates/backstage-app-config.yaml b/contrib/chart/backstage/templates/backstage-app-config.yaml new file mode 100644 index 0000000000..f06e47feab --- /dev/null +++ b/contrib/chart/backstage/templates/backstage-app-config.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "backstage.fullname" . }}-app-config +data: +{{ include "backstage.appConfigFilename" . | indent 2 }}: | +{{ tpl (.Files.Get "files/app-config.development.yaml.tpl") . | indent 4 }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "backstage.fullname" . }}-app-env +data: + APP_CONFIG_app_baseUrl: {{ .Values.appConfig.app.baseUrl | quote | quote }} + APP_CONFIG_app_title: {{ .Values.appConfig.app.title | quote | quote }} + APP_CONFIG_app_googleAnalyticsTrackingId: {{ .Values.appConfig.app.googleAnalyticsTrackingId | quote | quote }} + APP_CONFIG_backend_baseUrl: {{ .Values.appConfig.backend.baseUrl | quote | quote }} + APP_CONFIG_backend_cors_origin: {{ .Values.appConfig.backend.cors.origin | quote | quote }} + APP_CONFIG_techdocs_storageUrl: {{ .Values.appConfig.techdocs.storageUrl | quote | quote }} + APP_CONFIG_techdocs_requestUrl: {{ .Values.appConfig.techdocs.requestUrl | quote | quote }} + APP_CONFIG_lighthouse_baseUrl: {{ .Values.appConfig.lighthouse.baseUrl | quote | quote }} + APP_CONFIG_backend_database_connection_ssl_rejectUnauthorized: "false" + APP_CONFIG_auth_providers_github_development_appOrigin: {{ .Values.appConfig.auth.providers.github.development.appOrigin | quote | quote }} + APP_CONFIG_auth_providers_google_development_appOrigin: {{ .Values.appConfig.auth.providers.google.development.appOrigin | quote | quote }} + APP_CONFIG_auth_providers_gitlab_development_appOrigin: {{ .Values.appConfig.auth.providers.gitlab.development.appOrigin | quote | quote }} + APP_CONFIG_auth_providers_okta_development_appOrigin: {{ .Values.appConfig.auth.providers.okta.development.appOrigin | quote | quote }} + APP_CONFIG_auth_providers_oauth2_development_appOrigin: {{ .Values.appConfig.auth.providers.oauth2.development.appOrigin | quote | quote }} + diff --git a/contrib/chart/backstage/templates/backstage-auth-config.yaml b/contrib/chart/backstage/templates/backstage-auth-config.yaml new file mode 100644 index 0000000000..7dcf8379aa --- /dev/null +++ b/contrib/chart/backstage/templates/backstage-auth-config.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "backstage.fullname" . }}-auth +data: + AUTH_GOOGLE_CLIENT_ID: {{ .Values.auth.google.clientId }} + AUTH_GITHUB_CLIENT_ID: {{ .Values.auth.github.clientId }} + AUTH_GITLAB_CLIENT_ID: {{ .Values.auth.gitlab.clientId }} + # This should not be prefixed with AUTH_. This could be a typo in the Backstage app config. + # Regardless, it is not decided by me. + GITLAB_BASE_URL: {{ .Values.auth.gitlab.baseUrl }} + AUTH_OKTA_CLIENT_ID: {{ .Values.auth.okta.clientId }} + AUTH_OKTA_AUDIENCE: {{ .Values.auth.okta.audience }} + AUTH_OAUTH2_CLIENT_ID: {{ .Values.auth.oauth2.clientId }} + AUTH_OAUTH2_AUTH_URL: {{ .Values.auth.oauth2.authUrl }} + AUTH_OAUTH2_TOKEN_URL: {{ .Values.auth.oauth2.tokenUrl }} + AUTH_AUTH0_CLIENT_ID: {{ .Values.auth.auth0.clientId }} + AUTH_AUTH0_DOMAIN: {{ .Values.auth.auth0.domain }} + AUTH_MICROSOFT_CLIENT_ID: {{ .Values.auth.microsoft.clientId }} + AUTH_MICROSOFT_TENANT_ID: {{ .Values.auth.microsoft.tenantId }} + diff --git a/contrib/chart/backstage/templates/frontend-deployment.yaml b/contrib/chart/backstage/templates/frontend-deployment.yaml new file mode 100644 index 0000000000..5430ef041f --- /dev/null +++ b/contrib/chart/backstage/templates/frontend-deployment.yaml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "backstage.fullname" . }}-frontend + +spec: + replicas: {{ .Values.frontend.replicaCount }} + + selector: + matchLabels: + app: backstage + component: frontend + + template: + metadata: + annotations: + ad.datadoghq.com/backstage.logs: '[{"source":"backstage","service":"frontend"}]' + labels: + app: backstage + component: frontend + + spec: + {{- if .Values.dockerRegistrySecretName }} + imagePullSecrets: + - name: {{ .Values.dockerRegistrySecretName }} + {{- end}} + containers: + - name: {{ .Chart.Name }}-frontend + image: {{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }} + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - containerPort: {{ .Values.frontend.containerPort }} + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + envFrom: + - configMapRef: + name: {{ include "backstage.fullname" . }}-app-env + volumeMounts: + - name: app-config + mountPath: {{ printf "/usr/share/nginx/html/static/%s" (include "backstage.appConfigFilename" .) }} + subPath: {{ include "backstage.appConfigFilename" . }} + volumes: + - name: app-config + configMap: + name: {{ include "backstage.fullname" . }}-app-config +{{- if .Values.frontend.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "frontend.serviceName" . }} + +spec: + ports: + - port: 80 + targetPort: {{ .Values.frontend.containerPort }} + + selector: + app: backstage + component: frontend + + type: ClusterIP +{{- end }} diff --git a/contrib/chart/backstage/templates/ingress.yaml b/contrib/chart/backstage/templates/ingress.yaml new file mode 100644 index 0000000000..7231afda13 --- /dev/null +++ b/contrib/chart/backstage/templates/ingress.yaml @@ -0,0 +1,92 @@ +{{- $frontendUrl := urlParse .Values.appConfig.app.baseUrl}} +{{- $backendUrl := urlParse .Values.appConfig.backend.baseUrl}} +{{- $lighthouseUrl := urlParse .Values.appConfig.lighthouse.baseUrl}} +apiVersion: networking.k8s.io/v1beta1 +kind: Ingress +metadata: + name: {{ include "backstage.fullname" . }}-ingress + annotations: + {{- if .Values.issuer.email }} + cert-manager.io/cluster-issuer: {{ .Values.issuer.clusterIssuer }} + {{- end }} + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/ssl-redirect: "false" + nginx.ingress.kubernetes.io/configuration-snippet: | + if ($scheme = https) { + add_header Strict-Transport-Security "max-age=0;"; + } + {{- toYaml .Values.ingress.annotations | nindent 4 }} +spec: + tls: + - secretName: {{ include "backstage.fullname" . }}-tls + hosts: + - {{ $frontendUrl.host }} + - {{ $backendUrl.host }} + - {{ $lighthouseUrl.host }} + + rules: + - host: {{ $frontendUrl.host }} + http: + paths: + - path: / + backend: + serviceName: {{ include "frontend.serviceName" . }} + servicePort: 80 + {{/* Route the backend inside the same hostname as the frontend when they are the same */}} + {{- if eq $frontendUrl.host $backendUrl.host}} + - path: /api + backend: + serviceName: {{ include "backend.serviceName" . }} + servicePort: 80 + {{/* Route the backend through a different host */}} + {{- else -}} + - host: {{ $backendUrl.host }} + http: + paths: + - path: {{ $backendUrl.path | default "/" }} + backend: + serviceName: {{ include "backend.serviceName" . }} + servicePort: 80 + {{- end }} + +{{/* Route lighthouse through a different host */}} +{{- if not ( eq $frontendUrl.host $lighthouseUrl.host ) }} + - host: {{ $lighthouseUrl.host }} + http: + paths: + - path: {{ $lighthouseUrl.path | default "/" }} + backend: + serviceName: {{ include "lighthouse.serviceName" . }} + servicePort: 80 +{{- else }} +{{/* Route lighthouse by path with re-write rules when it is hosted under the same hostname */}} +--- +apiVersion: networking.k8s.io/v1beta1 +kind: Ingress +metadata: + name: {{ include "backstage.fullname" . }}-ingress-lighthouse + annotations: + {{- if .Values.issuer.email }} + cert-manager.io/cluster-issuer: {{ .Values.issuer.clusterIssuer }} + {{- end }} + nginx.ingress.kubernetes.io/rewrite-target: /$2 + nginx.ingress.kubernetes.io/ssl-redirect: "false" + nginx.ingress.kubernetes.io/configuration-snippet: | + if ($scheme = https) { + add_header Strict-Transport-Security "max-age=0;"; + } + {{- toYaml .Values.ingress.annotations | nindent 4 }} +spec: + tls: + - secretName: {{ include "backstage.fullname" . }}-tls + hosts: + - {{ $lighthouseUrl.host }} + rules: + - host: {{ $frontendUrl.host }} + http: + paths: + - path: {{$lighthouseUrl.path}}(/|$)(.*) + backend: + serviceName: {{ include "lighthouse.serviceName" . }} + servicePort: 80 +{{- end }} diff --git a/contrib/chart/backstage/templates/issuer.yaml b/contrib/chart/backstage/templates/issuer.yaml new file mode 100644 index 0000000000..d129c8c701 --- /dev/null +++ b/contrib/chart/backstage/templates/issuer.yaml @@ -0,0 +1,19 @@ +{{- if (and (.Capabilities.APIVersions.Has "cert-manager.io/v1alpha2") .Values.issuer.email ) -}} +{{/* Only install issuer if it doesn't already exist in the cluster */}} +{{- if not ( lookup "cert-manager.io/v1alpha2" "ClusterIssuer" "" .Values.issuer.clusterIssuer ) }} +apiVersion: cert-manager.io/v1alpha2 +kind: ClusterIssuer +metadata: + name: {{ .Values.issuer.clusterIssuer }} +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: {{ required "expected a valid .Values.issuer.email to enable ClusterIssuer" .Values.issuer.email }} + privateKeySecretRef: + name: {{ required "expected .Values.issuer.cluster-issuer to not be empty (letsencrypt-prod | letsencrypt-staging)" .Values.issuer.clusterIssuer }} + solvers: + - http01: + ingress: + class: nginx +{{- end -}} +{{- end -}} diff --git a/contrib/chart/backstage/templates/lighthouse-config.yaml b/contrib/chart/backstage/templates/lighthouse-config.yaml new file mode 100644 index 0000000000..fdf4bd8b06 --- /dev/null +++ b/contrib/chart/backstage/templates/lighthouse-config.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "backstage.fullname" . -}}-lighthouse +data: + PGDATABASE: {{ .Values.lighthouse.database.connection.database | quote }} + PGUSER: {{ include "lighthouse.postgresql.user" . | quote }} + PGPORT: {{ include "lighthouse.postgresql.port" . | quote }} + PGHOST: {{ include "lighthouse.postgresql.host" . | quote }} + PGPATH_TO_CA: {{ include "backstage.lighthouse.postgresCaFilename" . | quote }} \ No newline at end of file diff --git a/contrib/chart/backstage/templates/lighthouse-deployment.yaml b/contrib/chart/backstage/templates/lighthouse-deployment.yaml new file mode 100644 index 0000000000..13bb8eadbb --- /dev/null +++ b/contrib/chart/backstage/templates/lighthouse-deployment.yaml @@ -0,0 +1,78 @@ +{{- if .Values.lighthouse.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "backstage.fullname" . }}-lighthouse + +spec: + replicas: {{ .Values.lighthouse.replicaCount }} + + selector: + matchLabels: + app: backstage + component: lighthouse-audit-service + + template: + metadata: + annotations: + ad.datadoghq.com/backstage.logs: '[{"source":"backstage","service":"lighthouse"}]' + labels: + app: backstage + component: lighthouse-audit-service + + spec: + {{- if .Values.dockerRegistrySecretName }} + imagePullSecrets: + - name: {{ .Values.dockerRegistrySecretName }} + {{- end}} + containers: + - name: lighthouse-audit-service + image: {{ .Values.lighthouse.image.repository }}:{{ .Values.lighthouse.image.tag }} + imagePullPolicy: {{ .Values.lighthouse.image.pullPolicy }} + ports: + - containerPort: {{ .Values.lighthouse.containerPort }} + resources: + {{- toYaml .Values.lighthouse.resources | nindent 12 }} + + envFrom: + - configMapRef: + name: {{ include "backstage.fullname" . -}}-lighthouse + - configMapRef: + name: {{ include "backstage.fullname" . }}-app-env + + env: + - name: LAS_PORT + value: {{ .Values.lighthouse.containerPort | quote }} + - name: LAS_CORS + value: "true" + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ include "lighthouse.postgresql.passwordSecret" . }} + key: postgresql-password + + volumeMounts: + - name: postgres-ca + mountPath: {{ include "backstage.lighthouse.postgresCaDir" . }} + + volumes: + - name: postgres-ca + configMap: + name: {{ include "backstage.fullname" . }}-postgres-ca +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "lighthouse.serviceName" . }} + +spec: + ports: + - port: 80 + targetPort: {{ .Values.lighthouse.containerPort }} + + selector: + app: backstage + component: lighthouse-audit-service + + type: ClusterIP +{{- end }} diff --git a/contrib/chart/backstage/templates/postgresql-ca-config.yaml b/contrib/chart/backstage/templates/postgresql-ca-config.yaml new file mode 100644 index 0000000000..2631989f4e --- /dev/null +++ b/contrib/chart/backstage/templates/postgresql-ca-config.yaml @@ -0,0 +1,21 @@ +{{- if .Values.postgresql.enabled }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "backstage.fullname" . }}-postgres-ca + labels: + app: {{ include "backstage.postgresql.fullname" . }} + release: {{ .Release.Name }} + annotations: + "helm.sh/hook": "pre-install" + "helm.sh/hook-delete-policy": "before-hook-creation" +data: + {{ .Values.global.postgresql.caFilename }}: | +{{ include "backstage.postgresql.generateCA" . | indent 4}} +{{- else }} +{{- $caConfig := printf "%s-postgres-ca" (include "backstage.fullname" .) }} +{{- if not ( lookup "v1" "ConfigMap" .Release.Namespace $caConfig ) }} +{{- fail (printf "\n\nPlease create the '%s' configmap with the CA certificate for your existing postgresql: kubectl create configmap %s --from-file=ca.crt" $caConfig $caConfig) }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/contrib/chart/backstage/templates/postgresql-certs-secret.yaml b/contrib/chart/backstage/templates/postgresql-certs-secret.yaml new file mode 100644 index 0000000000..c6845caa7e --- /dev/null +++ b/contrib/chart/backstage/templates/postgresql-certs-secret.yaml @@ -0,0 +1,16 @@ +{{- if .Values.postgresql.enabled }} +--- +apiVersion: v1 +kind: Secret +type: kubernetes.io/tls +metadata: + name: {{ required ".Values.postgresql.tls.certificatesSecret is required" .Values.postgresql.tls.certificatesSecret }} + labels: + app: {{ include "backstage.postgresql.fullname" . }} + release: {{ .Release.Name }} + annotations: + "helm.sh/hook": "pre-install" + "helm.sh/hook-delete-policy": "before-hook-creation" +data: +{{ include "generateCerts" . | indent 2 }} +{{- end }} diff --git a/contrib/chart/backstage/templates/postgresql-initdb-secret.yaml b/contrib/chart/backstage/templates/postgresql-initdb-secret.yaml new file mode 100644 index 0000000000..d1c446f79f --- /dev/null +++ b/contrib/chart/backstage/templates/postgresql-initdb-secret.yaml @@ -0,0 +1,14 @@ +{{- if .Values.postgresql.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ required ".Values.postgresql.initdbScriptsSecret is required" .Values.postgresql.initdbScriptsSecret }} + annotations: + "helm.sh/hook": "pre-install" + "helm.sh/hook-delete-policy": "before-hook-creation" +type: Opaque +data: + create-backend-dbs.sql: | + {{ tpl (.Files.Get "files/create-backend-dbs.sql") . | b64enc }} +{{- end }} + diff --git a/contrib/chart/backstage/templates/postgresql-password-secret.yaml b/contrib/chart/backstage/templates/postgresql-password-secret.yaml new file mode 100644 index 0000000000..e53369adb9 --- /dev/null +++ b/contrib/chart/backstage/templates/postgresql-password-secret.yaml @@ -0,0 +1,30 @@ +{{- if not .Values.postgresql.enabled }} +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: {{ include "backend.postgresql.passwordSecret" . }} + labels: + release: {{ .Release.Name }} + annotations: + "helm.sh/hook": "pre-install,pre-upgrade" + "helm.sh/hook-delete-policy": "before-hook-creation" +data: + postgresql-password: {{ .Values.appConfig.backend.database.connection.password | b64enc }} +{{- end }} +{{- if not .Values.postgresql.enabled }} +--- +apiVersion: v1 +kind: Secret +type: Opaque +metadata: + name: {{ include "lighthouse.postgresql.passwordSecret" . }} + labels: + release: {{ .Release.Name }} + annotations: + "helm.sh/hook": "pre-install,pre-upgrade" + "helm.sh/hook-delete-policy": "before-hook-creation" +data: + postgresql-password: {{ .Values.lighthouse.database.connection.password | b64enc }} +{{- end }} diff --git a/contrib/chart/backstage/templates/tests/test-app-connection.yaml b/contrib/chart/backstage/templates/tests/test-app-connection.yaml new file mode 100644 index 0000000000..93f51f3f09 --- /dev/null +++ b/contrib/chart/backstage/templates/tests/test-app-connection.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "backstage.fullname" . -}}-test-app-connection + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test-app + image: busybox + command: + - /bin/sh + - -ecx + - | + echo -e "===== Testing the connection with the frontend...\n" + wget -q -O - {{ printf "%s.%s" (include "frontend.serviceName" .) .Release.Namespace | quote }} + echo -e "\n\n===== Testing the connection with the backend...\n" + wget -q -O - {{ printf "http://%s.%s/catalog/entities" (include "backend.serviceName" .) .Release.Namespace | quote }} + echo -e "\n\n===== Testing the connection with the lighthouse plugin...\n" + wget -q -O - {{ printf "%s.%s/v1/audits" (include "lighthouse.serviceName" .) .Release.Namespace | quote }} + echo -e "\n" + restartPolicy: Never \ No newline at end of file diff --git a/contrib/chart/backstage/templates/tests/test-postgresql.yaml b/contrib/chart/backstage/templates/tests/test-postgresql.yaml new file mode 100644 index 0000000000..8eec1db452 --- /dev/null +++ b/contrib/chart/backstage/templates/tests/test-postgresql.yaml @@ -0,0 +1,33 @@ +{{- if .Values.postgresql.enabled}} +--- +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "backstage.name" . -}}-test-postgres + annotations: + "helm.sh/hook": test +spec: + containers: + - name: postgresql-client + image: bitnami/postgresql + env: + - name: PG_HOST + value: {{ include "postgresql.serviceName" . | quote }} + - name: PG_PORT + value: {{ .Values.postgresql.service.port | quote }} + - name: PG_USER + value: {{ .Values.global.postgresql.postgresqlUsername | quote }} + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ include "backend.postgresql.passwordSecret" .}} + key: postgresql-password + - name: PG_DBNAME + value: {{ .Values.appConfig.backend.database.connection.database }} + command: + - /bin/bash + - -ecx + - | + psql --host=$PG_HOST --port=$PG_PORT --username=$PG_USER --dbname=$PG_DBNAME --no-password + restartPolicy: Never +{{- end }} \ No newline at end of file diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml new file mode 100644 index 0000000000..a4a0fadcc2 --- /dev/null +++ b/contrib/chart/backstage/values.yaml @@ -0,0 +1,252 @@ +# Default values for backstage. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +frontend: + enabled: true + replicaCount: 1 + image: + repository: martinaif/backstage-k8s-demo-frontend + tag: test1 + pullPolicy: IfNotPresent + containerPort: 80 + resources: + requests: + memory: 128Mi + limits: + memory: 256Mi + +backend: + enabled: true + nodeEnv: development + demoData: true + replicaCount: 1 + image: + repository: martinaif/backstage-k8s-demo-backend + tag: test1 + pullPolicy: IfNotPresent + containerPort: 7000 + resources: + requests: + memory: 512Mi + limits: + memory: 1024Mi + +lighthouse: + enabled: true + replicaCount: 1 + image: + repository: roadiehq/lighthouse-audit-service + tag: latest + pullPolicy: IfNotPresent + containerPort: 3003 + resources: + requests: + memory: 128Mi + limits: + memory: 256Mi + database: + connection: + port: + host: + user: + password: + database: lighthouse_audit_service + pathToDatabaseCa: + +nameOverride: '' +fullnameOverride: '' + +ingress: + annotations: + kubernetes.io/ingress.class: nginx + +issuer: + email: + clusterIssuer: 'letsencrypt-staging' + +global: + postgresql: + postgresqlUsername: backend-user + caFilename: ca.crt + +postgresql: + enabled: true + nameOverride: postgresql + tls: + enabled: true + certificatesSecret: backstage-postgresql-certs + certFilename: tls.crt + certKeyFilename: tls.key + volumePermissions: + enabled: true + initdbScriptsSecret: backstage-postgresql-initdb + +appConfig: + app: + baseUrl: https://demo.example.com + title: Backstage + googleAnalyticsTrackingId: + backend: + baseUrl: https://demo.example.com + listen: + port: 7000 + cors: + origin: https://demo.example.com + database: + client: pg + connection: + database: backstage_plugin_catalog + host: + user: + port: + password: + ssl: + rejectUnauthorized: false + ca: + sentry: + organization: example-org-name + techdocs: + storageUrl: https://demo.example.com/api/techdocs/static/docs + requestUrl: https://demo.example.com/api/techdocs + lighthouse: + baseUrl: https://demo.example.com/lighthouse-api + rollbar: + organization: example-org-name + + # Auth config has recently moved into the app config file in upstream Backstage. However, + # most of this config simply mandates that items like the client id and client secret should + # be picked up from the environment variables named below. Those environment variables are + # set in this helm controlled environment by the 'auth' configuration below this section. + # Thus, the only key in this config which directly controls an app config is the + # auth.providers.github.development.appOrigin property. + auth: + providers: + google: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_GOOGLE_CLIENT_ID + clientSecret: + $secret: + env: AUTH_GOOGLE_CLIENT_SECRET + github: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_GITHUB_CLIENT_ID + clientSecret: + $secret: + env: AUTH_GITHUB_CLIENT_SECRET + enterpriseInstanceUrl: + $secret: + env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + gitlab: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_GITLAB_CLIENT_ID + clientSecret: + $secret: + env: AUTH_GITLAB_CLIENT_SECRET + audience: + $secret: + env: GITLAB_BASE_URL + okta: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_OKTA_CLIENT_ID + clientSecret: + $secret: + env: AUTH_OKTA_CLIENT_SECRET + audience: + $secret: + env: AUTH_OKTA_AUDIENCE + oauth2: + development: + appOrigin: 'http://localhost:3000/' + secure: false + clientId: + $secret: + env: AUTH_OAUTH2_CLIENT_ID + clientSecret: + $secret: + env: AUTH_OAUTH2_CLIENT_SECRET + authorizationURL: + $secret: + env: AUTH_OAUTH2_AUTH_URL + tokenURL: + $secret: + env: AUTH_OAUTH2_TOKEN_URL + auth0: + development: + clientId: + $secret: + env: AUTH_AUTH0_CLIENT_ID + clientSecret: + $secret: + env: AUTH_AUTH0_CLIENT_SECRET + domain: + $secret: + env: AUTH_AUTH0_DOMAIN + microsoft: + development: + clientId: + $secret: + env: AUTH_MICROSOFT_CLIENT_ID + clientSecret: + $secret: + env: AUTH_MICROSOFT_CLIENT_SECRET + tenantId: + $secret: + env: AUTH_MICROSOFT_TENANT_ID + +auth: + google: + clientId: a + clientSecret: a + github: + clientId: c + clientSecret: c + gitlab: + clientId: b + clientSecret: b + baseUrl: b + okta: + clientId: b + clientSecret: b + audience: b + oauth2: + clientId: b + clientSecret: b + authUrl: b + tokenUrl: b + auth0: + clientId: b + clientSecret: b + domain: b + microsoft: + clientId: f + clientSecret: f + tenantId: f + azure: + api: + token: h + sentryToken: e + rollbarAccountToken: f + # This is a 'Personal Access Token' + circleciAuthToken: r + # Used by the scaffolder to create GitHub repos. Must have 'repo' scope. + githubToken: g + gitlabToken: g + newRelicRestApiKey: r + travisciAuthToken: fake-travis-ci-auth-token diff --git a/contrib/docker/kubernetes-example-backend/Dockerfile b/contrib/docker/kubernetes-example-backend/Dockerfile new file mode 100644 index 0000000000..b7d7a9631e --- /dev/null +++ b/contrib/docker/kubernetes-example-backend/Dockerfile @@ -0,0 +1,33 @@ +FROM node:12-buster + +WORKDIR /usr/src/app + +# (workaround) Install cookiecutter and mkdocs to avoid the need to run docker in docker +RUN cd /tmp && curl -O https://www.python.org/ftp/python/3.8.2/Python-3.8.2.tar.xz && \ + tar -xvf Python-3.8.2.tar.xz && \ + cd Python-3.8.2 && \ + ./configure --enable-optimizations && \ + make -j 4 && \ + make altinstall + +RUN apt update +RUN apt install -y mkdocs + +RUN pip3.8 install mkdocs-techdocs-core + +RUN pip3.8 install cookiecutter && \ + apt remove -y build-essential zlib1g-dev libncurses5-dev libgdbm-dev libnss3-dev libssl-dev libsqlite3-dev libreadline-dev libffi-dev libbz2-dev g++ python-pip python-dev && \ + rm -rf /var/cache/apt/* /tmp/Python-3.8.2 + +# Copy repo skeleton first, to avoid unnecessary docker cache invalidation. +# The skeleton contains the package.json of each package in the monorepo, +# and along with yarn.lock and the root package.json, that's enough to run yarn install. +ADD yarn.lock package.json skeleton.tar ./ + +RUN yarn install --frozen-lockfile --production + +# This will copy the contents of the dist-workspace when running the build-image command. +# Do not use this Dockerfile outside of that command, as it will copy in the source code instead. +COPY . . + +CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.development.yaml"] diff --git a/contrib/docker/kubernetes-example-backend/README.md b/contrib/docker/kubernetes-example-backend/README.md new file mode 100644 index 0000000000..d0f9d57022 --- /dev/null +++ b/contrib/docker/kubernetes-example-backend/README.md @@ -0,0 +1,13 @@ +# Example backend Dockerfile + +This Dockerfile will build the example backend with certain additional binaries needed to workaround +the docker requirement in the scaffolder and techdocs. + +# Usage + +```bash +yarn docker-build -f --tag +``` + +> The absolute path is necessary as this directory is not copied to the build workspace when building +> the docker image. diff --git a/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md new file mode 100644 index 0000000000..f5288cf144 --- /dev/null +++ b/contrib/docs/tutorials/help-im-behind-a-corporate-proxy.md @@ -0,0 +1,58 @@ +# Running the backend behind a Corporate Proxy + +Let's admit it, we've all been there. Sometimes you've gotta run stuff with no way out to the public internet, only the smallest of corporate proxy tunnels. + +Whilst this isn't supported natively by Backstage, this might help you get your installation up and running making calls through the said proxy tunnel. + +Unfortunately, `nodejs` does not respect `HTTP(S)_PROXY` environment variables by default, and the library that we use to provide `fetch` functionality `node-fetch` (provided by `cross-fetch`) does not also respect these environment variables. + +There are however some ways to get this to work without too much effort. It's most likely that you're going to run into these issues from the `backend` part of `backstage` as that's the part that isn't helped by your browser or OS's settings for the corporate proxy. + +**Note:** You're gonna want to be in your backend working directory for these solutions as that's where the requests come from that don't go through this proxy. + +### Using `global-agent` + +1. Install `global-agent` using `yarn add global-agent` +2. Go to the entry file for the backend (`src/index.ts`) +3. At the top of the file paste the following: + +```ts +import 'global-agent/bootstrap'; +``` + +4. Start the backend with the `global-agent` variables + +```sh +export GLOBAL_AGENT_HTTP_PROXY=$HTTP_PROXY +yarn start +``` + +More information and more options for configuring `global-agent` including just using the default environment variables can be found here: https://github.com/gajus/global-agent + +### Using `proxy-agent` + +`proxy-agent` is a library that you can use to override the `globalAgents` of `node` land with a tunnel to use for each request. + +1. Install `proxy-agent` using `yarn add proxy-agent` +2. Go to the entry file for the backend (`src/index.ts`) +3. At the top of the file paste the following: + +```ts +import ProxyAgent from 'proxy-agent'; +import http from 'http'; +import https from 'https'; + +/* + Something to note here, this might need different configuration depending on your own setup. + If you only have an http_proxy then you'll need to set that as both the http and https globalAgent instead. +*/ +if (process.env.HTTP_PROXY) { + http.globalAgent = new ProxyAgent(process.env.HTTP_PROXY); +} + +if (process.env.HTTPS_PROXY) { + https.globalAgent = new ProxyAgent(process.env.HTTPS_PROXY); +} +``` + +4. Start the backend with `yarn start` diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md index 855608c8b1..5d633544e3 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md @@ -9,7 +9,6 @@ import { InfoCard, Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -25,7 +24,7 @@ const ExampleComponent: FC<{}> = () => { const profile = identityApi.getProfile(); return ( - +
{ diff --git a/docs/architecture-decisions/adr008-default-catalog-file-name.md b/docs/architecture-decisions/adr008-default-catalog-file-name.md index d4716318bc..6794dc87cc 100644 --- a/docs/architecture-decisions/adr008-default-catalog-file-name.md +++ b/docs/architecture-decisions/adr008-default-catalog-file-name.md @@ -11,7 +11,7 @@ While the spec for the catalog file format is well described in to the name of the catalog file. Following discussion in -[Issue 1822](https://github.com/spotify/backstage/pull/1822#pullrequestreview-461253670), +[Issue 1822](https://github.com/backstage/backstage/pull/1822#pullrequestreview-461253670), a decision was made. ## Name diff --git a/docs/architecture-decisions/adr009-entity-references.md b/docs/architecture-decisions/adr009-entity-references.md index 8b7984ea49..a6fe3ee583 100644 --- a/docs/architecture-decisions/adr009-entity-references.md +++ b/docs/architecture-decisions/adr009-entity-references.md @@ -13,7 +13,7 @@ There was also some confusion on how to reference entities in URLs in the Backstage frontend. Following discussion in -[Issue 1947](https://github.com/spotify/backstage/issues/1947), a decision was +[Issue 1947](https://github.com/backstage/backstage/issues/1947), a decision was made. ## Entity References in YAML files diff --git a/docs/architecture-decisions/index.md b/docs/architecture-decisions/index.md index ddf2805e75..3211f37550 100644 --- a/docs/architecture-decisions/index.md +++ b/docs/architecture-decisions/index.md @@ -25,9 +25,9 @@ Records should be stored under the `architecture-decisions` directory. - Address and integrate feedback from the community - Eventually, assign a number - Add the path of the ADR to the microsite sidebar in - [`sidebars.json`](https://github.com/spotify/backstage/blob/master/microsite/sidebars.json) + [`sidebars.json`](https://github.com/backstage/backstage/blob/master/microsite/sidebars.json) - Add the path of the ADR to the - [`mkdocs.yml`](https://github.com/spotify/backstage/blob/master/mkdocs.yml) + [`mkdocs.yml`](https://github.com/backstage/backstage/blob/master/mkdocs.yml) - Merge the pull request ## Superseding an ADR diff --git a/docs/auth/auth-backend-classes.md b/docs/auth/auth-backend-classes.md index 9dc677b439..442553d4b8 100644 --- a/docs/auth/auth-backend-classes.md +++ b/docs/auth/auth-backend-classes.md @@ -20,7 +20,7 @@ Each of these methods is hosted at an endpoint `/auth/[provider]/method`, where ``` For more information on how these methods are used and for which purpose, refer -to the documentation [here](oauth.md). +to the [OAuth documentation](oauth.md). For details on the parameters, input and output conditions for each method, refer to the type documentation under @@ -38,7 +38,7 @@ Currently OAuth is assumed to be the de facto authentication mechanism for Backstage based applications. Backstage comes with a "batteries-included" set of supported commonly used OAuth -providers: Okta, Github, Google, Gitlab, and a generic OAuth2 provider. +providers: Okta, GitHub, Google, GitLab, and a generic OAuth2 provider. All of these use the authorization flow of OAuth2 to implement authentication. diff --git a/docs/auth/index.md b/docs/auth/index.md index d7fb5d8c59..3f95efaf61 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -58,7 +58,7 @@ token used to make authenticated calls within Backstage. The middleware that will be provided by `@backstage/backend-common` allows verification of Backstage ID tokens, and optionally loading additional information about the user. The progress is tracked in -https://github.com/spotify/backstage/issues/1435. +https://github.com/backstage/backstage/issues/1435. #### Identity for App Developers diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index 178b99b153..0655175b58 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -15,10 +15,10 @@ to various third party APIs. There are occasions when the user wants to perform actions towards third party services that require authorization via OAuth. Backstage provides standardized [Utility APIs](../api/utility-apis.md) such as the -[GoogleAuthApi](https://github.com/spotify/backstage/blob/master/packages/core-api/src/apis/definitions/auth.ts) +[GoogleAuthApi](https://github.com/backstage/backstage/blob/master/packages/core-api/src/apis/definitions/auth.ts) for that use-case. Backstage also includes a set of implementations of these APIs that integrate with the -[auth-backend](https://github.com/spotify/backstage/tree/master/plugins/auth-backend) +[auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend) plugin to provide a popup-based OAuth flow. ## Background @@ -58,9 +58,9 @@ easier to make authenticated requests inside a plugin. ## OAuth Flow The following describes the OAuth flow implemented by the -[auth-backend](https://github.com/spotify/backstage/tree/master/plugins/auth-backend) +[auth-backend](https://github.com/backstage/backstage/tree/master/plugins/auth-backend) and -[DefaultAuthConnector](https://github.com/spotify/backstage/blob/master/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts) +[DefaultAuthConnector](https://github.com/backstage/backstage/blob/master/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts) in `@backstage/core-api`. Component and APIs can request Access or ID Tokens from any available Auth diff --git a/docs/conf/defining.md b/docs/conf/defining.md index bea03e4e44..ead5c4ff67 100644 --- a/docs/conf/defining.md +++ b/docs/conf/defining.md @@ -4,16 +4,109 @@ title: Defining Configuration for your Plugin description: Documentation on Defining Configuration for your Plugin --- -There is currently no tooling support or helpers for defining plugin -configuration. But it's on the roadmap. +Configuration in Backstage is organized via a configuration schema, which in +turn is defined using a superset of +[JSON Schema Draft-07](https://json-schema.org/specification-links.html#draft-7). +Each plugin or package within a Backstage app can contribute to the schema, +which during validation is stitched together into a single schema. -Meanwhile, document the config values that you are reading in your plugin -README. +## Schema Collection and Definition -## Format +Schemas are collected from all packages and dependencies in each repo that are a +part of the Backstage ecosystem, including transitive dependencies. The current +definition of "part of the ecosystem" is that a package has at least one +dependency in the `@backstage` namespace, but this is subject to change. + +Each package is searched for a schema at a single point of entry, a top-level +`"configSchema"` field in `package.json`. The field can either contain an +inlined JSON schema, or a relative path to a schema file. Supported schema file +formats are `.json` or `.d.ts`. + +> When defining a schema file, be sure to include the file in your +> `package.json` > `"files"` field as well! + +TypeScript configuration schema files should export a single `Config` type, for +example: + +```ts +export interface Config { + app: { + /** + * Frontend root URL + * @visibility frontend + */ + baseUrl: string; + }; +} +``` + +Separate `.json` schema files can use a top-level +`"$schema": "https://backstage.io/schema/config-v1"` declaration in order to +receive schema validation and autocompletion. For example: + +```json +{ + "$schema": "https://backstage.io/schema/config-v1", + "type": "object", + "properties": { + "app": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "Frontend root URL", + "visibility": "frontend" + } + }, + "required": ["baseUrl"] + }, + "required": ["app"] + } +} +``` + +## Visibility + +The `https://backstage.io/schema/config-v1` meta schema is a superset of JSON +Schema Draft 07. The single addition is a custom `visibility` keyword, which is +used to indicate whether the given config value should be visible in the +frontend or not. The possible values are `frontend`, `backend`, and `secret`, +where `backend` is the default. A visibility of `secret` has the same scope at +runtime, but it will be treated with more care in certain contexts, and defining +both `frontend` and `secret` for the same value in two different schemas will +result in an error during schema merging. + +The visibility only applies to the direct parent of where the keyword is placed +in the schema. For example, if you set the visibility to `frontend` for a subset +of the schema with `type: "object"`, but none of the descendants, only an empty +object will be available in the frontend. The full ancestry does not need to +have correctly defined visibilities however, so it is enough to only for example +declare the visibility of a leaf node of `type: "string"`. + +## Validation + +Schemas can be validated using the `backstage-cli config:check` command. If you +want to validate anything else than the default `app-config.yaml`, be sure to +pass in all of the configuration files as `--config ` options as well. + +To validate and examine the frontend configuration, use the +`backstage-cli config:print --frontend` command. Just like for validation you +may need to pass in all files using one or multiple `--config ` options. + +## Guidelines + +> Make limited use of static configuration. The first question to ask is whether +> a particular option actually needs to be static configuration, or if it might +> just as well be a TypeScript API. In general, options that you want to be able +> to change for different deployment environments should be static +> configuration, while it should otherwise be avoided. When defining configuration for your plugin, keep keys camelCased and stick to -existing casing conventions such as `baseUrl`. +existing casing conventions such as `baseUrl` rather than `baseURL`. It is also usually best to prefer objects over arrays, as it makes it possible to override individual values using separate files or environment variables. + +Avoid creating new top-level fields as much as possible. Either place your +configuration within an existing known top-level block, or create a single new +one using e.g. the name of the product that the plugin integrates. diff --git a/docs/conf/index.md b/docs/conf/index.md index e1dfc30582..a6f1d1f6f7 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -15,11 +15,11 @@ allowing for customization. ## Supplying Configuration -Configuration is stored in `app-config.yaml` files, with support for suffixes -such as `app-config.production.yaml` to override values for specific -environments. The configuration files themselves contain plain YAML, but with -support for loading in secrets from various sources using for example `$env` and -`$file` keys. +Configuration is stored in YAML files where the defaults are `app-config.yaml` +and `app-config.local.yaml` for local overrides. Other sets of files can by +loaded by passing `--config ` flags. The configuration files themselves +contain plain YAML, but with support for loading in secrets from various sources +using for example `$env` and `$file` keys. It is also possible to supply configuration through environment variables, for example `APP_CONFIG_app_baseUrl=https://staging.example.com`. However these @@ -28,11 +28,25 @@ development or small tweaks to be able to reuse deployment artifacts in different environments. The configuration is shared between the frontend and backend, meaning that -values that are common between the two only needs to be defined once. Such as -the `backend.baseUrl`. +values that are common between the two only need to be defined once. Such as the +`backend.baseUrl`. For more details, see [Writing Configuration](./writing.md). +## Configuration Schema + +The configuration is validated using JSON Schema definitions. Each plugin and +package can provide pieces of the configuration schema, which are stitched +together to form a complete schema during validation. The configuration schema +is also used to select what configuration is available in the frontend using a +custom `visibility` keyword, as configuration is by default only available in +the backend. + +You can validate your configuration against the schema using +`backstage-cli config:check`, and define a schema for your own plugin either +using JSON Schema or TypeScript. For more information, see +[Defining Configuration](./defining.md). + ## Reading Configuration As a plugin developer, you likely end up wanting to define configuration that @@ -49,5 +63,5 @@ More details are provided in dedicated sections of the documentation. plugin. - [Writing Configuration](./writing.md): How to provide configuration for your Backstage deployment. -- [Defining Configuration](./defining.md): How to define configuration for users - of your plugin. +- [Defining Configuration](./defining.md): How to define a configuration schema + for users of your plugin or package. diff --git a/docs/conf/reading.md b/docs/conf/reading.md index a43ec8c026..6d4b61224d 100644 --- a/docs/conf/reading.md +++ b/docs/conf/reading.md @@ -117,7 +117,7 @@ The [ConfigApi](../reference/utility-apis/Config.md) in the frontend is a Depending on the config api in another API is slightly different though, as the `ConfigApi` implementation is supplied via the App itself and not instantiated like other APIs. See -[packages/app/src/apis.ts](https://github.com/spotify/backstage/blob/244eef851f5aa19f91c7c9b5c12d5df95cf482ca/packages/app/src/apis.ts#L66) +[packages/app/src/apis.ts](https://github.com/backstage/backstage/blob/244eef851f5aa19f91c7c9b5c12d5df95cf482ca/packages/app/src/apis.ts#L66) for an example of how this wiring is done. For standalone plugin setups in `dev/index.ts`, register a factory with a @@ -129,4 +129,4 @@ from `@backstage/core`. In backend plugins the configuration is passed in via options from the main backend package. See for example -[packages/backend/src/plugins/auth.ts](https://github.com/spotify/backstage/blob/244eef851f5aa19f91c7c9b5c12d5df95cf482ca/packages/backend/src/plugins/auth.ts#L23). +[packages/backend/src/plugins/auth.ts](https://github.com/backstage/backstage/blob/244eef851f5aa19f91c7c9b5c12d5df95cf482ca/packages/backend/src/plugins/auth.ts#L23). diff --git a/docs/conf/writing.md b/docs/conf/writing.md index 5564e780bb..f28dd5b2d3 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -19,7 +19,7 @@ backend: baseUrl: http://localhost:7000 organization: - name: Spotify + name: CNCF proxy: /my/api: @@ -55,19 +55,28 @@ picked up by the serve tasks of `@backstage/cli` for local development, and are injected by the entrypoint of the nginx container serving the frontend in a production build. -## File Resolution +## Configuration Files It is possible to have multiple configuration files, both to support different environments, but also to define configuration that is local to specific -packages. +packages. The configuration files to load are selected using a `--config ` +flag, and it is possible to load any number of files. Paths are relative to the +working directory of the executed process, for example `package/backend`. This +means that to select a config file in the repo root when running the backend, +you would use `--config ../../my-config.yaml`. -All `app-config.yaml` files inside the monorepo root and package root are -considered, as are files with additional `local` and environment affixes such as -`development`, for example `app-config.local.yaml`, -`app-config.production.yaml`, and `app-config.development.local.yaml`. Which -environment config files are loaded is determined by the `NODE_ENV` environment -variable. Local configuration files are always loaded, but are meant for local -development overrides and should typically be `.gitignore`'d. +If no `config` flags are specified, the default behavior is to load +`app-config.yaml` and, if it exists, `app-config.local.yaml` from the repo root. +In the provided project setup, `app-config.local.yaml` is `.gitignore`'d, making +it a good place to add config overrides and secrets for local development. + +Note that if any config flags are provided, the default `app-config.yaml` files +are NOT loaded. To include them you need to explicitly include them with a flag, +for example: + +```shell +yarn start --config ../../app-config.yaml --config ../../app-config.staging.yaml +``` All loaded configuration files are merged together using the following rules: @@ -83,15 +92,15 @@ order: - Configuration from the `APP_CONFIG_` environment variables has the highest priority, followed by files. -- Files inside package directories have higher priority than those in the root - directory. -- Files with environment affixes have higher priority than ones without. -- Files with the `local` affix have higher priority than ones without. +- Files loaded with config flags are ordered by priority, where the last flag + has the highest priority. +- If no config flags are provided, `app-config.local.yaml` has higher priority + than `app-config.yaml`. -## Secrets +## Secrets and Dynamic Data -Secrets are supported via a special secret keys that are prefixed with `$`, -which in turn provides a number of different ways to read in secrets. To load a +Secrets are supported via special data loading keys that are prefixed with `$`, +which in turn provide a number of different ways to read in secrets. To load a configuration value as a secret, supply an object with one of the special secret keys, for example `$env` or `$file`. A full list of supported secret keys can be found below. For example, the following will read the config key @@ -108,10 +117,6 @@ will return the value of the environment variable `MY_SECRET_KEY` when the backend started up. All secrets are loaded at startup, so changing the contents of secret files or environment variables will not be reflected at runtime. -Note that secrets will never be included in the frontend bundle or development -builds. When loading configuration you have to explicitly enable reading of -secrets, which is only done for the backend configuration. - As hinted at, secrets can be loaded from a bunch of different sources, and can be extended with more. Below is a list of the currently supported methods for loading secrets. @@ -146,8 +151,11 @@ used to point to a specific value inside the file. Supported file extensions are ```yaml $data: ./my-secrets.json#deployment.key +``` -# my-secrets.json +Example `my-secrets.json` file: + +```json { "deployment": { "key": "my-secret-key" diff --git a/docs/dls/design.md b/docs/dls/design.md index fa3a0c159e..3bf283b77f 100644 --- a/docs/dls/design.md +++ b/docs/dls/design.md @@ -46,9 +46,9 @@ referencing Figma documents to share specs and prototypes with the community. ### Creating a New Design Component -| Step 1 | Step 2 | Step 3 | Step 4 | Step 5 | Step 6 | -| :------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------- | :--------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- | -| Platform design team submits an issue to **spotify/Backstage GitHub** with a potential component. | Backstage community offers feedback or approval on **spotify/Backstage GitHub**. | Platform design team adjusts accordingly (as they see fit) and update the Figma DLS document. | Designed component is added to **spotify/Backstage GitHub** as an issue. | External or internal Backstage open source contributors build the component. | External or internal contributors add the component to the **Backstage Storybook**. 🎉 | +| Step 1 | Step 2 | Step 3 | Step 4 | Step 5 | Step 6 | +| :-------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :--------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- | +| Platform design team submits an issue to **backstage/backstage GitHub** with a potential component. | Backstage community offers feedback or approval on **backstage/backstage GitHub**. | Platform design team adjusts accordingly (as they see fit) and update the Figma DLS document. | Designed component is added to **backstage/backstage GitHub** as an issue. | External or internal Backstage open source contributors build the component. | External or internal contributors add the component to the **Backstage Storybook**. 🎉 | ### Building for Backstage @@ -56,9 +56,9 @@ referencing Figma documents to share specs and prototypes with the community. | :------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------- | | External or internal contributors use Backstage and come up with an idea of an entity to build for Backstage. | External or internal contributors refer to the Backstage Open Source design system documentation in the Figma DLS document. | External or internal contributors leverage the components and tokens from the Backstage Storybook. | External or internal contributors build their Backstage entity. | -| Step 5 | Step 6 | Step 7 | Step 8 | -| :------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------ | -| External or internal contributors make a pull request for their entity on spotify/Backstage GitHub for review. | Platform designers and devs review the entity and submit feedback or approval on spotify/Backstage GitHub. | External or internal contributors make the changes, pull request is approved and the entity is merged. It’s live on Backstage! 🎉 | If the entity happens to be or include a UX component, it’s added to Backstage Storybook as well. | +| Step 5 | Step 6 | Step 7 | Step 8 | +| :--------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------ | +| External or internal contributors make a pull request for their entity on backstage/backstage GitHub for review. | Platform designers and devs review the entity and submit feedback or approval on backstage/backstage GitHub. | External or internal contributors make the changes, pull request is approved and the entity is merged. It’s live on Backstage! 🎉 | If the entity happens to be or include a UX component, it’s added to Backstage Storybook as well. | The following diagram shows the relationship between the Backstage Design System and our foundation, which comprises of [Material UI](https://material-ui.com/) @@ -99,9 +99,10 @@ issues in GitHub with ‘design’ and/or ‘storybook’ - so feel free to brow tackle the tasks that interest you. If you have any questions regarding an issue, you can ask them in the comments section of the issue or on [Discord](https://discord.gg/EBHEGzX). We absolutely adore our external -contributors and will send you virtual semlas for your contributions! +contributors and will send you virtual +[semlas](https://en.wikipedia.org/wiki/Semla) for your contributions! -### Request a component. +### Request a component Create an issue (label it design and assign it to katz95) or send us a message on [Discord](https://discord.gg/EBHEGzX) (_#design_ channel) with details of @@ -135,5 +136,3 @@ contributing to Backstage as a designer is easy. From styling guidelines to UX principles to Figma documents, we’ll make sure you’re equipped to chip in on this project. We’re excited to work with you! In the meantime, we’d love to hear from you on [Discord](https://discord.gg/EBHEGzX). - -[Back to Docs](../README.md) diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md index 46de2d3d67..d10fb29e30 100644 --- a/docs/features/software-catalog/configuration.md +++ b/docs/features/software-catalog/configuration.md @@ -1,5 +1,5 @@ --- -id: software-catalog-configuration +id: configuration title: Catalog Configuration description: Documentation on Software Catalog Configuration --- @@ -89,7 +89,7 @@ the catalog under the `catalog.locations` key, for example: catalog: locations: - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml ``` The locations added through static configuration can not be removed through the diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 0c50e3669a..5d7a5b9af0 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -2,8 +2,8 @@ id: descriptor-format title: Descriptor Format of Catalog Entities sidebar_label: YAML File Format -description: Documentation on Descriptor Format of Catalog Entities which -describes the default data shape and semantics of catalog entities +# prettier-ignore +description: Documentation on Descriptor Format of Catalog Entities which describes the default data shape and semantics of catalog entities --- This section describes the default data shape and semantics of catalog entities. @@ -12,18 +12,25 @@ This both applies to objects given to and returned from the software catalog API, as well as to the descriptor files that the software catalog can ingest natively. In the API request/response cycle, a JSON representation is used, while the descriptor files are on YAML format to be more easily maintainable by -humans. However, the structure and semantics is the same in both cases. +humans. However, the structure and semantics are the same in both cases. + +Although it's possible to name catalog entity descriptor files however you wish, +we recommend that you name them `catalog-info.yaml`. ## Contents - [Overall Shape Of An Entity](#overall-shape-of-an-entity) - [Common to All Kinds: The Envelope](#common-to-all-kinds-the-envelope) - [Common to All Kinds: The Metadata](#common-to-all-kinds-the-metadata) +- [Common to All Kinds: Relations](#common-to-all-kinds-relations) - [Kind: Component](#kind-component) - [Kind: Template](#kind-template) - [Kind: API](#kind-api) - [Kind: Group](#kind-group) - [Kind: User](#kind-user) +- [Kind: Resource](#kind-resource) +- [Kind: System](#kind-system) +- [Kind: Domain](#kind-domain) ## Overall Shape Of An Entity @@ -110,7 +117,7 @@ data. Backstage specific entities have an `apiVersion` that is prefixed with `backstage.io/`, to distinguish them from other types of object that share the same type of structure. This may be relevant when co-hosting these -specifications with e.g. kubernetes object manifests, or when an organization +specifications with e.g. Kubernetes object manifests, or when an organization adds their own specific kinds of entity to the catalog. Early versions of the catalog will be using alpha/beta versions, e.g. @@ -253,8 +260,63 @@ component, like `java` or `go`. This field is optional, and currently has no special semantics. -Each tag must be sequences of `[a-zA-Z0-9]` separated by `-`, at most 63 -characters in total. +Each tag must be sequences of `[a-z0-9]` separated by `-`, at most 63 characters +in total. + +## Common to All Kinds: Relations + +The `relations` root field is a read-only list of relations, between the current +entity and other entities, described in the +[well-known relations section](well-known-relations.md). Relations are commonly +two-way, so that there's a pair of relation types each describing one direction +of the relation. + +A relation as part of a single entity that's read out of the API may look as +follows. + +```js +{ + // ... + "relations": [ + { + "target": { + "kind": "group", + "namespace": "default", + "name": "dev.infra" + }, + "type": "ownedBy" + } + ], + "spec": { + "owner": "dev.infra", + // ... + } +} +``` + +The fields of a relation are: + +| Field | Type | Description | +| ---------- | ------ | -------------------------------------------------------------------------------- | +| `target` | Object | A complete [compound reference](references.md) to the other end of the relation. | +| `type` | String | The type of relation FROM a source entity TO the target entity. | +| `metadata` | Object | Reserved for future use. | + +Entity descriptor YAML files are not supposed to contain this field. Instead, +catalog processors analyze the entity descriptor data and its surroundings, and +deduce relations that are then attached onto the entity as read from the +catalog. + +Where relations are produced, they are to be considered the authoritative source +for that piece of data. In the example above, a plugin would do better to +consume the relation rather than `spec.owner` for deducing the owner of the +entity, because it may even be the case that the owner isn't taken from the YAML +at all - it could be taken from a CODEOWNERS file nearby instead for example. +Also, the `spec.owner` is on a shortened form and may have semantics associated +with it (such as the default kind being `Group` if not specified). + +See the [well-known relations section](well-known-relations.md) for a list of +well-known / common relations and their semantics. ## Kind: Component @@ -415,7 +477,7 @@ of the `metadata.name` field. ### `metadata.tags` [optional] A list of strings that can be associated with the template, e.g. -`['Recommended', 'React']`. +`['recommended', 'react']`. This list will also be used in the frontend to display to the user so you can potentially search and group templates by these tags. @@ -737,3 +799,15 @@ with the default kind `Group` and the default namespace equal to the same namespace as the user. Only `Group` entities may be referenced. Most commonly, these entries point to groups in the same namespace, so in those cases it is sufficient to enter only the `metadata.name` field of those groups. + +## Kind: Resource + +This kind is not yet defined, but is reserved [for future use](system-model.md). + +## Kind: System + +This kind is not yet defined, but is reserved [for future use](system-model.md). + +## Kind: Domain + +This kind is not yet defined, but is reserved [for future use](system-model.md). diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index b045660202..cbacce40c7 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -4,6 +4,36 @@ title: Extending the model description: Documentation on Extending the model --- +The Backstage catalog [entity data model](descriptor-format.md) is based on the +[Kubernetes objects format](https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/), +and borrows a lot of its semantics as well. This page describes those semantics +at a higher level and how to extend them to fit your organization. + +Backstage comes with a number of catalog concepts out of the box: + +- There are a number of builtin versioned _kinds_, such as `Component`, `User` + etc. These encapsulate the high level concept of an entity, and define the + schema for its entity definition data. +- An entity has both a _metadata_ object and a _spec_ object at the root. +- Each kind may or may not have a _type_. For example, there are several well + known types of component, such as `service` and `website`. These clarify the + more detailed nature of the entity, and may affect what features are exposed + in the interface. +- Entities may have a number of _[annotations](well-known-annotations.md)_ on + them. These can be added either by humans into the descriptor files, or added + by automated processes when the entity is ingested into the catalog. +- Entities may have a number of _labels_ on them. +- Entities may have a number of _relations_, expressing how they relate to each + other in different ways. + +We'll list different possibilities for extending this below. + +## Adding a New Kind + +> TODO: Fill in + +## Adding a New Type of an Existing Kind + Backstage natively supports tracking of the following component [`type`](descriptor-format.md)'s: @@ -20,13 +50,6 @@ track in Backstage, it is possible to add your own software types that fit your organization's data model. Inside Spotify our model has grown significantly over the years, and now includes ML models, Apps, data pipelines and many more. -## Adding a new type - -TODO: Describe what changes are needed to add a new type that shows up in the -catalog. - -## The Other type - It might be tempting to put software that doesn't fit into any of the existing types into Other. There are a few reasons why we advise against this; firstly, we have found that it is preferred to match the conceptual model that your @@ -36,6 +59,32 @@ through plugins. Different plugins are used for managing different types of components. For example, the -[Lighthouse plugin](https://github.com/spotify/backstage/tree/master/plugins/lighthouse) +[Lighthouse plugin](https://github.com/backstage/backstage/tree/master/plugins/lighthouse) only makes sense for Websites. The more specific you can be in how you model your software, the easier it is to provide plugins that are contextual. + +> TODO: Fill in + +## Changing the Validation Rules for Core Entity Fields + +> TODO: Fill in + +## Adding New Fields to the Metadata Object + +> TODO: Fill in + +## Adding New Fields to the Spec Object of an Existing Kind + +> TODO: Fill in + +## Adding a New Annotation + +> TODO: Fill in + +## Adding a New Label + +> TODO: Fill in + +## Adding a New Relation Type + +> TODO: Fill in diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md index 00a9d95fc2..e7d253f11e 100644 --- a/docs/features/software-catalog/external-integrations.md +++ b/docs/features/software-catalog/external-integrations.md @@ -1,13 +1,163 @@ --- id: external-integrations title: External integrations -description: Documentation on External integrations to integrate systems -with Backstage +# prettier-ignore +description: Documentation on External integrations to integrate systems with Backstage --- -Backstage natively supports storing software components in -[metadata YAML files](descriptor-format.md). However, companies that already -have an existing system for keeping track of software and its owners can -integrate such systems with Backstage. +Backstage natively supports importing catalog data through the use of +[entity descriptor YAML files](descriptor-format.md). However, companies that +already have an existing system for keeping track of software and its owners can +leverage those systems by integrating them with Backstage. This article shows +the most common way of doing that integration: by adding a custom catalog +_processor_. -TODO: Describe the API contract. +## Background + +The catalog has a frontend plugin part, that communicates via a service API to +the backend plugin part. The backend has a processing loop that repeatedly +ingests data from the sources you specify, to store them in its database. + +As a Backstage adopter, you would be able to customize or extend the catalog in +several ways - by replacing the entire backend API, or by replacing entire +implementation classes at certain points in the backend, or by leveraging the +ingestion process to fetch data from your own authoritative source. Each method +has benefits and drawbacks, but this article will focus on the last one of the +above. It is the one that is the most straight forward and future proof, and +leverages a lot of benefits that come with the builtin catalog. + +## Processors and the Ingestion Loop + +The catalog holds a number of registered locations, that were added either by +site admins or by individual Backstage users. Their purpose is to reference some +sort of data that the catalog shall keep itself up to date with. Each location +has a `type`, and a `target` that are both strings. + +```yaml +# Example location +type: url +target: https://github.com/backstage/backstage/blob/master/catalog-info.yaml +``` + +The builtin catalog backend has an ingestion loop that periodically goes through +all of these registered locations, and pushes them and their resulting output +through the list of _processors_. + +Processors are classes that the site admin has registered with the catalog at +startup. They are at the heart of all catalog logic, and have the ability to +read the contents of locations, modify in-flight entities that were read out of +a location, perform validation, and more. The catalog comes with a set of +builtin processors, that have the ability to read from a list of well known +location types, to perform the basic processing needs, etc, but more can be +added by the organization that adopts Backstage. + +We will now show the process of creating a new processor and location type, +which enables the ingestion of catalog data from an existing external API. + +## Deciding on the New Locations + +The first step is to decide how we want to point at the system that holds our +data. Let's assume that it is internally named System-X and can be reached +through HTTP REST calls to its API. + +Let's decide that our locations shall take the following form: + +```yaml +type: system-x +target: http://systemx.services.example.net/api/v2 +``` + +It got its own made-up `type`, and the `target` conveniently points to the +actual API endpoint to talk to. + +So now we have to make the catalog aware of such a location so that it can start +feeding it into the ingestion loop. For this kind of an integration, you'd +typically want to add it to the list of statically always-available locations in +the config. + +```yaml +# In app-config.yaml +catalog: + locations: + - type: system-x + target: http://systemx.services.example.net/api/v2 +``` + +If you start up the backend now, it will start to periodically say that it could +not find a processor that supports that location. So let's make a processor that +does so! + +## Creating a Catalog Data Reader Processor + +The recommended way of instantiating the catalog backend classes is to use the +[`CatalogBuilder`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/service/CatalogBuilder.ts), +as illustrated in the +[example backend here](https://github.com/backstage/backstage/blob/master/packages/backend/src/plugins/catalog.ts). +We will create a new +[`CatalogProcessor`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/ingestion/types.ts) +subclass that can be added to this catalog builder. + +It is up to you where you put the code for this new processor class. For quick +experimentation you could place it in your backend package, but we recommend +putting all extensions like this in a backend plugin package of their own in the +`plugins` folder of your Backstage repo. + +The class will have this basic structure: + +```ts +import { UrlReader } from '@backstage/backend-common'; +import { LocationSpec } from '@backstage/catalog-model'; +import { + results, + CatalogProcessor, + CatalogProcessorEmit, +} from '@backstage/plugin-catalog-backend'; + +// A processor that reads from the fictional System-X +export class SystemXReaderProcessor implements CatalogProcessor { + constructor(private readonly reader: UrlReader) {} + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + ): Promise { + // Pick a custom location type string. A location will be + // registered later with this type. + if (location.type !== 'system-x') { + return false; + } + + try { + // Use the builtin reader facility to grab data from the + // API. If you prefer, you can just use plain fetch here + // (from the cross-fetch package), or any other method of + // your choosing. + const data = await this.reader.read(location.target); + const json = JSON.parse(data.toString()); + // Repeatedly call emit(results.entity(location, )) + } catch (error) { + const message = `Unable to read ${location.type}, ${error}`; + emit(results.generalError(location, message)); + } + + return true; + } +} +``` + +The key points to note are: + +- Make a class that implements `CatalogProcessor` +- Only act on location types that you care about, and leave the rest alone by + returning `false` +- Read the data from the external system in any way you see fit. Use the + location `target` field if you designed it as mentioned above +- Call `emit` any number of times with the results of that process +- Finally return `true` + +You should now be able to instantiate this class in your backend, and add it to +the `CatalogBuilder` using the `addProcessors` method. + +Start up the backend - it should now start reading from the previously +registered location and you'll see your entities start to appear in Backstage. diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md index 4ba83146db..70541b85db 100644 --- a/docs/features/software-catalog/index.md +++ b/docs/features/software-catalog/index.md @@ -2,8 +2,8 @@ id: software-catalog-overview title: Backstage Service Catalog (alpha) sidebar_label: Overview -description: The Backstage Service Catalog — actually, a software catalog, since -it includes more than just services +# prettier-ignore +description: The Backstage Service Catalog — actually, a software catalog, since it includes more than just services --- ## What is a Service Catalog? @@ -19,7 +19,7 @@ are then harvested and visualized in Backstage. ## How it works -Backstage and the Backstage Service Catalog makes it easy for one team to manage +Backstage and the Backstage Service Catalog make it easy for one team to manage 10 services — and makes it possible for your company to manage thousands of them. @@ -33,10 +33,10 @@ More specifically, the Service Catalog enables two main use-cases: ## Getting Started -The Software Catalog is available to browse on the start page at `/`. If you've -followed [Installing in your Backstage App](./installation.md) in your separate -App or [Getting Started with Backstage](../../getting-started) for this repo, -you should be able to browse the catalog at `http://localhost:3000`. +The Software Catalog is available to browse at `/catalog`. If you've followed +[Installing in your Backstage App](./installation.md) in your separate App or +[Getting Started with Backstage](../../getting-started) for this repo, you +should be able to browse the catalog at `http://localhost:3000`. ![](../../assets/software-catalog/service-catalog-home.png) @@ -62,11 +62,11 @@ Users can register new components by going to `/create` and clicking the Backstage expects the full URL to the YAML in your source control. Example: ```bash -https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml +https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml ``` _More examples can be found -[here](https://github.com/spotify/backstage/tree/master/packages/catalog-model/examples)._ +[here](https://github.com/backstage/backstage/tree/master/packages/catalog-model/examples)._ ![](../../assets/software-catalog/bsc-register-2.png) @@ -91,7 +91,7 @@ above example can be added using the following configuration: catalog: locations: - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml ``` More information about catalog configuration can be found @@ -135,7 +135,7 @@ in the catalog. ![tools](https://backstage.io/blog/assets/20-05-20/tabs.png) The Backstage platform can be customized by incorporating -[existing open source plugins](https://github.com/spotify/backstage/tree/master/plugins), +[existing open source plugins](https://github.com/backstage/backstage/tree/master/plugins), or by [building your own](../../plugins/index.md). ## Links diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index 26990033aa..b2afd62878 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -56,7 +56,7 @@ The catalog components depend on a number of other [Utility APIs](../../api/utility-apis.md) to function, including at least the `ErrorApi` and `StorageApi`. You can find an example of how to install these in your app -[here](https://github.com/spotify/backstage/blob/61c3a7e5b750dc7c059ef16b188594d31b2c04c2/packages/app/src/apis.ts#L80). +[here](https://github.com/backstage/backstage/blob/61c3a7e5b750dc7c059ef16b188594d31b2c04c2/packages/app/src/apis.ts#L80). ## Gotchas that we will fix @@ -122,6 +122,7 @@ export default async function createPlugin({ entitiesCatalog, locationsCatalog, locationReader, + db, logger, ); @@ -167,21 +168,21 @@ catalog: locations: # Backstage Example Component - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/podcast-api-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/podcast-api-component.yaml - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/queue-proxy-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/queue-proxy-component.yaml - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/searcher-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/searcher-component.yaml - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-lib-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/playback-lib-component.yaml - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/www-artist-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/www-artist-component.yaml - type: url - target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/shuffle-api-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/shuffle-api-component.yaml ``` ### Running the Backend diff --git a/docs/features/software-catalog/system-model.md b/docs/features/software-catalog/system-model.md index 44dac13d65..53f49d5df9 100644 --- a/docs/features/software-catalog/system-model.md +++ b/docs/features/software-catalog/system-model.md @@ -8,7 +8,7 @@ We believe that a strong shared understanding and terminology around software and resources leads to a better Backstage experience. _This description originates from -[this RFC](https://github.com/spotify/backstage/issues/390). Note that some of +[this RFC](https://github.com/backstage/backstage/issues/390). Note that some of the concepts are not yet supported in Backstage._ ## Core Entities @@ -113,5 +113,5 @@ Backstage currently supports Components and APIs. ## Links -- [Original RFC](https://github.com/spotify/backstage/issues/390) +- [Original RFC](https://github.com/backstage/backstage/issues/390) - [YAML file format](../../architecture-decisions/adr002-default-catalog-file-format.md) diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 757e0461a9..4d227ea568 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -2,9 +2,8 @@ id: well-known-annotations title: Well-known Annotations on Catalog Entities sidebar_label: Well-known Annotations -description: Documentation on lists a number of well known Annotations, that -have defined semantics. They can be attached to catalog entities and consumed -by plugins as needed +# prettier-ignore +description: Documentation that lists a number of well known Annotations, that have defined semantics. They can be attached to catalog entities and consumed by plugins as needed. --- This section lists a number of well known @@ -23,7 +22,7 @@ use. # Example: metadata: annotations: - backstage.io/managed-by-location: github:http://github.com/spotify/backstage/catalog-info.yaml + backstage.io/managed-by-location: github:http://github.com/backstage/backstage/catalog-info.yaml ``` The value of this annotation is a so called location reference string, that @@ -41,34 +40,13 @@ expecting a two-item array out of it. The format of the target part is type-dependent and could conceivably even be an empty string, but the separator colon is always present. -### backstage.io/definition-at-location - -```yaml -# Example -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: petstore - annotations: - backstage.io/definition-at-location: 'url:https://petstore.swagger.io/v2/swagger.json' -spec: - type: openapi -``` - -This annotation allows to fetch an API definition from another location, instead -of wrapping the API definition inside the definition field. This allows to -easily consume existing API definition. The definition is fetched during -ingestion by a processor and included in the entity. It is updated on every -refresh. The annotation contains a location reference string that contains the -location processor type and the target. - ### backstage.io/techdocs-ref ```yaml # Example: metadata: annotations: - backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git + backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git ``` The value of this annotation is a location reference string (see above). If this @@ -96,7 +74,7 @@ that entity. # Example: metadata: annotations: - github.com/project-slug: spotify/backstage + github.com/project-slug: backstage/backstage ``` The value of this annotation is the so-called slug that identifies a project on @@ -114,7 +92,7 @@ that entity. # Example: metadata: annotations: - github.com/team-slug: spotify/backstage-core + github.com/team-slug: backstage/maintainers ``` The value of this annotation is the so-called slug that identifies a team on @@ -167,7 +145,7 @@ that entity. # Example: metadata: annotations: - rollbar.com/project-slug: spotify/pump-station + rollbar.com/project-slug: backstage/pump-station ``` The value of this annotation is the so-called slug (or alternatively, the ID) of @@ -179,6 +157,27 @@ fallback (`rollbar.organization` followed by `organization.name`). Specifying this annotation may enable Rollbar related features in Backstage for that entity. +### circleci.com/project-slug + +```yaml +# Example: +metadata: + annotations: + circleci.com/project-slug: github/spotify/pump-station +``` + +The value of this annotation is the so-called slug (or alternatively, the ID) of +a [CircleCI](https://circleci.com/) project within your organization. The value +can be the format of `[source-control-manager]/[organization]/[project-slug]` or +just `[organization]/[project-slug]`. When the `[source-control-manager]` slug +is omitted, `bitbucket` will be used as a fallback. + +Specifying this annotation will cause the CI/CD features in Backstage to display +data from CircleCI for that entity. + +Providing both the `github.com/project-slug` and `circleci.com/project-slug` +annotations can cause problems as both may be used for CI/CD features. + ### backstage.io/ldap-rdn, backstage.io/ldap-uuid, backstage.io/ldap-dn ```yaml @@ -194,6 +193,22 @@ The value of these annotations are the corresponding attributes that were found when ingestion the entity from LDAP. Not all of them may be present, depending on what attributes that the server presented at ingestion time. +### sonarqube.org/project-key + +```yaml +# Example: +metadata: + annotations: + sonarqube.org/project-key: pump-station +``` + +The value of this annotation is the project key of a +[SonarQube](https://sonarqube.org) or [SonarCloud](https://sonarcloud.io) +project within your organization. + +Specifying this annotation may enable SonarQube related features in Backstage +for that entity. + ## Deprecated Annotations The following annotations are deprecated, and only listed here to aid in @@ -205,6 +220,25 @@ This annotation was used for a while to enable the GitHub Actions feature. This is now instead using the [github.com/project-slug](#github-com-project-slug) annotation, with the same value format. +### backstage.io/definition-at-location + +This annotation allowed to load the API definition from another location. Now +placeholders can be used instead: + +``` +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: petstore + description: The Petstore API +spec: + type: openapi + lifecycle: production + owner: petstore@example.com + definition: + $text: https://petstore.swagger.io/v2/swagger.json +``` + ## Links - [Descriptor Format: annotations](descriptor-format.md#annotations-optional) diff --git a/docs/features/software-catalog/well-known-relations.md b/docs/features/software-catalog/well-known-relations.md new file mode 100644 index 0000000000..07a4fc0a5c --- /dev/null +++ b/docs/features/software-catalog/well-known-relations.md @@ -0,0 +1,82 @@ +--- +id: well-known-relations +title: Well-known Relations between Catalog Entities +sidebar_label: Well-known Relations +# prettier-ignore +description: Documentation that lists a number of well known Relations, that have defined semantics. They can be attached to catalog entities and consumed by plugins as needed. +--- + +This section lists a number of well known +[entity relation types](descriptor-format.md#common-to-all-kinds-relations), +that have defined semantics. They can be attached to catalog entities and +consumed by plugins as needed. + +If you are looking to extend the set of relations, see +[Extending the model](extending-the-model.md). + +## Relations + +This is a (non-exhaustive) list of relations that are known to be in active use. + +Each relation has a _source_ (implicitly: the entity that holds the relation), a +_target_ (the entity to which the source has a relation), and a _type_ that +tells what relation the source has with the target. The relation is directional; +there are commonly pairs of relation types and the entity at the other end will +have the opposite relation in the opposite direction (e.g. when querying for +`A`, you will see `A.ownedBy.B`, and when querying `B`, you will see +`B.ownerOf.A`). + +### `ownedBy` and `ownerOf` + +An ownership relation where the owner is usually an organizational entity +([User](descriptor-format.md#kind-user) or +[Group](descriptor-format.md#kind-group)), and the other entity can be anything. + +In Backstage, the owner of an entity is the singular entity (commonly a team) +that bears ultimate responsibility for the entity, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this relation is for display purposes in Backstage, so that people looking at +catalog entities can get an understanding of to whom this entity belongs. It is +not to be used by automated processes to for example assign authorization in +runtime systems. There may be others that also develop or otherwise touch the +entity, but there will always be one ultimate owner. + +This relation is commonly generated based on `spec.owner` of the owned entity, +where present. + +### `consumesApi` and `providesApi` + +A relation with an [API](descriptor-format.md#kind-api) entity, typically from a +[Component](descriptor-format.md#kind-component) or +[System](descriptor-format.md#kind-system). + +These relations express that a component or system either exposes an API - +meaning that it hosts callable endpoints from which you can consume that API - +or that they are dependent on being able to consume that API. + +This relation is commonly generated based on `spec.implementsApis` of the +component or system in question. + +### `dependsOn` and `dependencyOf` + +A relation denoting a dependency on another entity. + +This relation is a general expression of being in need of that other entity for +an entity to function. It can for example be used to express that a website +component needs a library component as part of its build, or that a service +component uses a persistent storage resource. + +### `parentOf` and `childOf` + +A parent/child relation to build up a tree, used for example to describe the +organizational structure between [Groups](descriptor-format.md#kind-group). + +This relation is commonly based on `spec.parent` and/or `spec.children`. + +### `memberOf` and `hasMember` + +A membership relation, typically for [Users](descriptor-format.md#kind-user) in +[Groups](descriptor-format.md#kind-group). + +This relation is commonly based on `spec.memberOf`. diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index 5a4e0049ab..75a75003f7 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -5,8 +5,8 @@ description: Documentation on Adding your own Templates --- Templates are stored in the **Service Catalog** under a kind `Template`. The -minimum that the template skeleton needs is a `template.yaml` but it would be -good to also have some files in there that can be templated in. +minimum that is needed to define a template is a `template.yaml` file, but it +would be good to also have some files in there that can be templated in. A simple `template.yaml` definition might look something like this: @@ -61,7 +61,7 @@ support to load the location will also need to be added to the Catalog. You can add the template files to the catalog through [static location configuration](../software-catalog/configuration.md#static-location-configuration), -for example +for example: ```yaml catalog: @@ -100,11 +100,11 @@ curl \ --location \ --request POST 'localhost:7000/api/catalog/locations' \ --header 'Content-Type: application/json' \ - --data-raw "{\"type\": \"github\", \"target\": \"https://${YOUR GITHUB REPO}blob/master/${PATH TO FOLDER}/template.yaml\"}" + --data-raw "{\"type\": \"github\", \"target\": \"https://${GITHUB URL}/${YOUR GITHUB ORG/REPO}/blob/master/${PATH TO FOLDER}/template.yaml\"}" ``` -This should then have added the catalog, and also should now be listed under the -create page at http://localhost:3000/create. +This should then have been added the catalog, and be listed under the create +page at http://localhost:3000/create. The `type` field which is chosen in the request to add the `template.yaml` to the Service Catalog here, will become the `PreparerKey` which will be used to diff --git a/docs/features/software-templates/extending/create-your-own-preparer.md b/docs/features/software-templates/extending/create-your-own-preparer.md index 80287c6ce1..5419748c3a 100644 --- a/docs/features/software-templates/extending/create-your-own-preparer.md +++ b/docs/features/software-templates/extending/create-your-own-preparer.md @@ -15,10 +15,10 @@ location protocols: - `github://` These two are added to the `PreparersBuilder` and then passed into the -`createRouter` function of the `@spotify/plugin-scaffolder-backend` +`createRouter` function of the `@backstage/plugin-scaffolder-backend`. -A full example backend can be found -[here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), +A full example backend can be found in +[`scaffolder.ts`](https://github.com/backstage/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), but it looks something like the following ```ts @@ -56,7 +56,7 @@ when added to the service catalog. You can see more about this `PreparerKey` here in [Register your own template](../adding-templates.md) **note:** Currently the catalog supports loading definitions from GitHub + Local -Files, which translate into the two `PreparerKeys` `file` and `github`. To load +Files, which translate into the two `PreparerKeys`: `file` and `github`. To load from other places, not only will there need to be another preparer, but the support to load the location will also need to be added to the Catalog. @@ -85,8 +85,8 @@ and put the contents into a temporary directory and return that directory path. Some good examples exist here: -- https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts -- https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +- https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +- https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts ### Registering your own Preparer diff --git a/docs/features/software-templates/extending/create-your-own-publisher.md b/docs/features/software-templates/extending/create-your-own-publisher.md index ecb097f9d9..97854c999a 100644 --- a/docs/features/software-templates/extending/create-your-own-publisher.md +++ b/docs/features/software-templates/extending/create-your-own-publisher.md @@ -18,11 +18,11 @@ Currently we provide the following `publishers`: - `github` This publisher is passed through to the `createRouter` function of the -`@spotify/plugin-scaffolder-backend`. Currently, only one publisher is +`@backstage/plugin-scaffolder-backend`. Currently, only one publisher is supported, but PR's are always welcome. An full example backend can be found -[here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), +[here](https://github.com/backstage/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), but it looks something like the following ```ts @@ -34,7 +34,7 @@ import { Octokit } from '@octokit/rest'; import type { PluginEnvironment } from '../types'; export default async function createPlugin({ logger }: PluginEnvironment) { - const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN }); + const githubClient = new Octokit({ auth: process.env.GITHUB_TOKEN }); const publisher = new GithubPublisher({ client: githubClient }); return await createRouter({ @@ -82,7 +82,7 @@ Now it's up to you to implement the `publish` function and return Some good examples exist here: -- https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +- https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts ### Registering your own Publisher diff --git a/docs/features/software-templates/extending/create-your-own-templater.md b/docs/features/software-templates/extending/create-your-own-templater.md index d4a6eef5d2..63acd38286 100644 --- a/docs/features/software-templates/extending/create-your-own-templater.md +++ b/docs/features/software-templates/extending/create-your-own-templater.md @@ -17,10 +17,10 @@ Currently we provide the following templaters: - `cookiecutter` This templater is added to the `TemplaterBuilder` and then passed into the -`createRouter` function of the `@spotify/plugin-scaffolder-backend` +`createRouter` function of the `@backstage/plugin-scaffolder-backend` An full example backend can be found -[here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), +[here](https://github.com/backstage/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), but it looks something like the following ```ts @@ -96,7 +96,7 @@ Now it's up to you to implement the `run` function, and then return a Some good examples exist here: -- https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +- https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts ### Registering your own Templater diff --git a/docs/features/software-templates/extending/index.md b/docs/features/software-templates/extending/index.md index 944bf0e3ca..aaf42d6b12 100644 --- a/docs/features/software-templates/extending/index.md +++ b/docs/features/software-templates/extending/index.md @@ -51,7 +51,7 @@ passed through to the scaffolder backend. ### How it works Most of the heavy lifting is done in the -[router.ts](https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/src/service/router.ts#L93) +[router.ts](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/service/router.ts#L93) file in the `scaffolder-backend` plugin. There are two routes defined in the router: `POST /v1/jobs` and diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index ae121f8029..3ebb426466 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -39,17 +39,17 @@ internally. ![Enter some variables](../../assets/software-templates/template-picked.png) After filling in these variables, you'll get some more fields to fill out which -are required for backstage usage: the owner, (which is a `user` in the backstage +are required for backstage usage: the owner (which is a `user` in the backstage system), the `storePath` (which right now must be a GitHub Organisation or -GitHub user), a non-existing github repository name in the format -`organisation/reponame`, and a GitHub team or user account which should be +GitHub user and a non-existing GitHub repository name in the format +`organisation/reponame`), and a GitHub team or user account which should be granted admin access to the repository. ![Enter backstage vars](../../assets/software-templates/template-picked-2.png) ### Run! -Once you've entered values and confirmed, you'll then get a modal with live +Once you've entered values and confirmed, you'll then get a popup box with live progress of what is currently happening with the creation of your template. ![Templating Running](../../assets/software-templates/running.png) @@ -70,6 +70,6 @@ you to the registered component in the catalog: ![Catalog](../../assets/software-templates/go-to-catalog.png) -And then you'll also be able to see it in the Catalog View table +And then you'll also be able to see it in the Catalog View table: ![Catalog](../../assets/software-templates/added-to-the-catalog-list.png) diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 60f73ad0c3..724b86aed1 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -111,44 +111,8 @@ export default async function createPlugin({ templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); - const filePreparer = new FilePreparer(); - const githubPreparer = new GithubPreparer(); - const gitlabPreparer = new GitlabPreparer(config); - const preparers = new Preparers(); - - preparers.register('file', filePreparer); - preparers.register('github', githubPreparer); - preparers.register('gitlab', gitlabPreparer); - preparers.register('gitlab/api', gitlabPreparer); - - const publishers = new Publishers(); - - const githubToken = config.getString('scaffolder.github.token'); - const repoVisibility = config.getString( - 'scaffolder.github.visibility', - ) as RepoVisibilityOptions; - - const githubClient = new Octokit({ auth: githubToken }); - const githubPublisher = new GithubPublisher({ - client: githubClient, - token: githubToken, - repoVisibility, - }); - publishers.register('file', githubPublisher); - publishers.register('github', githubPublisher); - - const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api'); - - if (gitLabConfig) { - const gitLabToken = gitLabConfig.getString('token'); - const gitLabClient = new Gitlab({ - host: gitLabConfig.getOptionalString('baseUrl'), - token: gitLabToken, - }); - const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); - publishers.register('gitlab', gitLabPublisher); - publishers.register('gitlab/api', gitLabPublisher); - } + const preparers = await Preparers.fromConfig(config, { logger }); + const publishers = await Publishers.fromConfig(config, { logger }); const dockerClient = new Docker(); return await createRouter({ @@ -192,11 +156,11 @@ catalog: locations: # Backstage Example Templates - type: url - target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml + target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml - type: url - target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml + target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml - type: url - target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml + target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml - type: url target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml ``` @@ -211,34 +175,38 @@ docs on creating private GitHub access tokens is available Note that the need for private GitHub access tokens will be replaced with GitHub Apps integration further down the line. -#### Github +#### GitHub -The Github access token is retrieved from environment variables via the config. +The GitHub access token is retrieved from environment variables via the config. The config file needs to specify what environment variable the token is retrieved from. Your config should have the following objects. You can configure who can see the new repositories that the scaffolder creates by specifying `visibility` option. Valid options are `public`, `private` and -`internal`. `internal` options is for GitHub Enterprise clients, which means -public within the organization. - -#### Gitlab - -For Gitlab, we currently support the configuration of the GitLab publisher and -allows to configure the private access token and the base URL of a GitLab -instance: +`internal`. The `internal` option is for GitHub Enterprise clients, which means +public within the enterprise. ```yaml scaffolder: github: token: - $env: GITHUB_ACCESS_TOKEN + $env: GITHUB_TOKEN visibility: public # or 'internal' or 'private' +``` + +#### GitLab + +For GitLab, we currently support the configuration of the GitLab publisher and +allows to configure the private access token and the base URL of a GitLab +instance: + +```yaml +scaffolder: gitlab: api: baseUrl: https://gitlab.com token: - $env: SCAFFOLDER_GITLAB_PRIVATE_TOKEN + $env: GITLAB_TOKEN ``` #### Azure DevOps @@ -255,7 +223,7 @@ scaffolder: baseUrl: https://dev.azure.com/{your-organization} api: token: - $env: AZURE_PRIVATE_TOKEN + $env: AZURE_TOKEN ``` ### Running the Backend @@ -265,7 +233,7 @@ backend with the new configuration: ```bash cd packages/backend -GITHUB_ACCESS_TOKEN= yarn start +GITHUB_TOKEN= yarn start ``` If you've also set up the frontend plugin, so you should be ready to go browse diff --git a/docs/features/techdocs/FAQ.md b/docs/features/techdocs/FAQ.md index e81e9d82c7..de424778cb 100644 --- a/docs/features/techdocs/FAQ.md +++ b/docs/features/techdocs/FAQ.md @@ -17,14 +17,12 @@ This page answers frequently asked questions about [TechDocs](README.md). TechDocs is using [MkDocs](https://www.mkdocs.org/) to build project documentation under the hood. Documentation built with the -[techdocs-container](https://github.com/spotify/backstage/blob/master/packages/techdocs-container/README.md) -is using the MkDocs -[Material Theme](https://github.com/squidfunk/mkdocs-material). +[techdocs-container](https://github.com/backstage/techdocs-container) is using +the MkDocs [Material Theme](https://github.com/squidfunk/mkdocs-material). #### What is the mkdocs-techdocs-core plugin? -The -[mkdocs-techdocs-core](https://github.com/spotify/backstage/blob/master/packages/techdocs-container/techdocs-core/README.md) +The [mkdocs-techdocs-core](https://github.com/backstage/mkdocs-techdocs-core) package is a MkDocs Plugin that works like a wrapper around multiple MkDocs plugins (e.g. [MkDocs Monorepo Plugin](https://github.com/spotify/mkdocs-monorepo-plugin)) as diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index cec5b63cc7..16131a1ded 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -40,10 +40,10 @@ about TechDocs and the philosophy in its | [TechDocs V.2 🔮⌛][v2] | Easy adoption of TechDocs (whatever environment you have) [See V.2 Use Cases.](#techdocs-v2) | | [TechDocs V.3 🔮⌛][v3] | Build a widget (plugin) framework so that contributors can easily contribute features to TechDocs - that others can use. [See V.3 Use Cases.](#techdocs-v3) | -[v0]: https://github.com/spotify/backstage/milestone/15 -[v1]: https://github.com/spotify/backstage/milestone/16 -[v2]: https://github.com/spotify/backstage/milestone/22 -[v3]: https://github.com/spotify/backstage/milestone/17 +[v0]: https://github.com/backstage/backstage/milestone/15 +[v1]: https://github.com/backstage/backstage/milestone/16 +[v2]: https://github.com/backstage/backstage/milestone/22 +[v3]: https://github.com/backstage/backstage/milestone/17 @@ -82,7 +82,9 @@ for companies to adopt. This involves (something like) the following work items. - “Solidify” work and “Mkdocs stabilization” work that has come out of our Q3 end-to-end work. - Improve/simplify the get up and running process. -- Introduce doc template Software Templates. +- Introduce new documentation templates. +- Extend the already existing docs-template to have options of different + documentation types. - Enable companies to choose their own storage (S3 for example). - Enable companies to choose their own source code hosting provider (GitHub, GitLab, and so). @@ -107,18 +109,25 @@ migrate Spotify's existing TechDocs features to open source. | ------------------------------------------- | -------------------------------------------------------- | | Frontend | [`@backstage/plugin-techdocs`][techdocs/frontend] | | Backend | [`@backstage/plugin-techdocs-backend`][techdocs/backend] | -| Docker Container (for generating doc sites) | [`packages/techdocs-container`][techdocs/container] | -| CLI (for local development) | [`packages/techdocs-cli`][techdocs/cli] | +| Docker Container (for generating doc sites) | [`techdocs-container`][techdocs/container] | +| CLI (for local development) | [`@techdocs/cli`][techdocs/cli] | [getting started]: getting-started.md [concepts]: concepts.md [creating and publishing documentation]: creating-and-publishing.md [faq]: FAQ.md 'Frequently asked questions' [techdocs/frontend]: - https://github.com/spotify/backstage/blob/master/plugins/techdocs + https://github.com/backstage/backstage/blob/master/plugins/techdocs [techdocs/backend]: - https://github.com/spotify/backstage/blob/master/plugins/techdocs-backend -[techdocs/container]: - https://github.com/spotify/backstage/blob/master/packages/techdocs-container -[techdocs/cli]: - https://github.com/spotify/backstage/blob/master/packages/techdocs-cli + https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend +[techdocs/container]: https://github.com/backstage/techdocs-container +[techdocs/cli]: https://github.com/backstage/techdocs-cli + +## Feedback + +We have created a sweet and short TechDocs user survey - +https://docs.google.com/forms/d/e/1FAIpQLSdn5Vn3MQhCdyYRuW8cMzZkMQF0bFxXYN168gZRvESLfJWVVg/viewform + +This is to gather inputs from you (the Backstage community) which will help us +best serve TechDocs adopters and existing users. Your inputs will shape our +roadmap and we will share it in the open. diff --git a/docs/features/techdocs/concepts.md b/docs/features/techdocs/concepts.md index 13144f8df0..1675c64288 100644 --- a/docs/features/techdocs/concepts.md +++ b/docs/features/techdocs/concepts.md @@ -14,7 +14,7 @@ The TechDocs Core Plugin is an [MkDocs](https://www.mkdocs.org/) plugin created as a wrapper around multiple MkDocs plugins and Python Markdown extensions to standardize the configuration of MkDocs used for TechDocs. -[TechDocs Core](https://github.com/spotify/backstage/blob/master/packages/techdocs-container/techdocs-core/README.md) +[TechDocs Core](https://github.com/backstage/mkdocs-techdocs-core) ### TechDocs container @@ -23,14 +23,14 @@ The TechDocs container is a Docker container available at pages, including stylesheets and scripts from Python flavored Markdown, through MkDocs. -[TechDocs Container](https://github.com/spotify/backstage/blob/master/packages/techdocs-container/README.md) +[TechDocs Container](https://github.com/backstage/techdocs-container) ### TechDocs publisher The `techdocs-backend` plugin currently comes with one publisher - `LocalPublish`. -[TechDocs Backend](https://github.com/spotify/backstage/tree/master/plugins/techdocs-backend) +[TechDocs Backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend) More standalone publishers will come in the near future... @@ -41,7 +41,7 @@ documentation for publishing. Currently it mostly acts as a wrapper around the TechDocs container and provides an easy-to-use interface for our docker container. -[TechDocs CLI](https://github.com/spotify/backstage/blob/master/packages/techdocs-cli/README.md) +[TechDocs CLI](https://github.com/backstage/techdocs-cli) ### TechDocs Reader @@ -53,7 +53,7 @@ The TechDocs Reader purpose is also to open up the opportunity to integrate TechDocs widgets for a customized full-featured TechDocs experience. ([coming soon V.3](./README.md#project-roadmap)) -[TechDocs Reader](https://github.com/spotify/backstage/blob/master/plugins/techdocs/src/reader/README.md) +[TechDocs Reader](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/reader/README.md) ### Transformers @@ -62,4 +62,4 @@ Reader. The reason why transformers were introduced was to provide a way to transform the HTML content on pre and post render (e.g. rewrite docs links or modify css). -[Transformers API docs](https://github.com/spotify/backstage/blob/master/plugins/techdocs/src/reader/README.md) +[Transformers API docs](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/reader/README.md) diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index e1adedf25e..38f452dcf3 100644 --- a/docs/features/techdocs/creating-and-publishing.md +++ b/docs/features/techdocs/creating-and-publishing.md @@ -19,12 +19,20 @@ This section will guide you through: ## Create a basic documentation setup +If you have an existing repository that you'd like to add documentation to, skip +to the +[Manually add documentation setup](#manually-add-documentation-setup-to-already-existing-repository) +section below. Otherwise, continue reading to start a documentation repo from +scratch. + ### Use the documentation template Your working Backstage instance should by default have a documentation template added. If not, follow these [instructions](../software-templates/installation.md#adding-templates) to add -the documentation template. +the documentation template. The template creates a component with only TechDocs +configuration and default markdown files as below mentioned in manual +documentation setup, and is otherwise empty. ![Documentation Template](../../assets/techdocs/documentation-template.png) @@ -35,9 +43,11 @@ setup for free. Prerequisities: -- `catalog-info.yml` file registered to Backstage. +- An existing component + [registered in backstage](../software-catalog/index.md#adding-components-to-the-catalog) + (e.g. via a `catalog-info.yaml` file). -Create a `mkdocs.yml` file in the root of the repository with the following +Create an `mkdocs.yml` file in the root of your repository with the following content: ```yaml @@ -50,8 +60,8 @@ plugins: - techdocs-core ``` -Update your `catalog-info.yaml` file in the root of the repository with the -following content: +Update your component's entity description by adding the following lines to its +`catalog-info.yaml` in the root of its repository: ```yaml metadata: @@ -59,7 +69,7 @@ metadata: backstage.io/techdocs-ref: dir:./ ``` -Create a `/docs` folder in the root of the project with at least a `index.md` +Create a `/docs` folder in the root of the project with at least an `index.md` file. _(If you add more markdown files, make sure to update the nav in the mkdocs.yml file to get a proper navigation for your documentation.)_ diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index 2e5c8d000d..e51c6a122e 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -58,7 +58,7 @@ The default storage and request URLs: ```yaml techdocs: storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/docs + requestUrl: http://localhost:7000/api/techdocs/ ``` If you want `techdocs-backend` to manage building and publishing, you want @@ -73,10 +73,10 @@ required. ### Disable Docker in Docker situation (Optional) -The TechDocs backend plugin runs a docker container with mkdocs to generate the -frontend of the docs from source files (Markdown). If you are deploying -Backstage using Docker, this will mean that your Backstage Docker container will -try to run another Docker container for TechDocs backend. +The TechDocs backend plugin runs a docker container with mkdocs installed to +generate the frontend of the docs from source files (Markdown). If you are +deploying Backstage using Docker, this will mean that your Backstage Docker +container will try to run another Docker container for TechDocs backend. To avoid this problem, we have a configuration available. You can set a value in your `app-config.yaml` that tells the techdocs generator if it should run the @@ -90,10 +90,23 @@ techdocs: ``` Setting `generators.techdocs` to `local` means you will have to make sure your -environment is compatible with techdocs. You will have to install the -`mkdocs-techdocs-container` and 'mkdocs' package from pip, as well as graphviz -and plantuml from your package manager. This has only been tested with python -3.7 and python 3.8. +environment is compatible with techdocs. + +You will have to install the `mkdocs` and `mkdocs-techdocs-core` package from +pip, as well as `graphviz` and `plantuml` from your OS package manager (e.g. +apt). See our +[Dockerfile](https://github.com/backstage/techdocs-container/blob/main/Dockerfile) +for the latest requirements. You should be trying to match your Dockerfile with +this one. + +Note: We recommend Python version 3.7 or higher. + +Caveat: Please install the `mkdocs-techdocs-core` package after all other Python +packages. The order is important to make sure we get correct version of some of +the dependencies. For example, we want `Markdown` version to be +[3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11). +You can also explicitly install `Markdown==3.2.2` after installing all other +Python packages. ## Run Backstage locally diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index 97876c2ba1..79cd405198 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -37,7 +37,7 @@ in combination with [createMuiTheme](https://material-ui.com/customization/theming/#createmuitheme-options-args-theme) from [@material-ui/core](https://www.npmjs.com/package/@material-ui/core). See the -[@backstage/theme source](https://github.com/spotify/backstage/tree/master/packages/theme/src) +[@backstage/theme source](https://github.com/backstage/backstage/tree/master/packages/theme/src) and the implementation of the `createTheme` function for how this is done. You can also create a theme from scratch that matches the `BackstageTheme` type diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 9be0e24452..c35eded6a5 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -13,7 +13,7 @@ need to run Backstage in your own environment. ## Create an app To create a Backstage app, you will need to have -[NodeJS](https://nodejs.org/en/download/) Active LTS Release installed +[Node.js](https://nodejs.org/en/download/) Active LTS Release installed (currently v12). Backstage provides a utility for creating new apps. It guides you through the @@ -124,3 +124,21 @@ the root directory: ```bash yarn workspace backend start ``` + +### Troubleshooting + +#### Cannot find module + +You may encounter an error similar to below: + +``` +internal/modules/cjs/loader.js:968 + throw err; + ^ + +Error: Cannot find module '../build/Debug/nodegit.node' +``` + +This can occur if an npm dependency is not completely installed. Because some +dependencies run a post-install script, ensure that both your npm and yarn +configs have the `ignore-scripts` flag set to `false`. diff --git a/docs/getting-started/deployment-helm.md b/docs/getting-started/deployment-helm.md new file mode 100644 index 0000000000..e1f3ea7629 --- /dev/null +++ b/docs/getting-started/deployment-helm.md @@ -0,0 +1,64 @@ +--- +id: deployment-helm +title: Helm +description: Documentation on Kubernetes and Helm Deployment +sidebar_label: Kubernetes and Helm +--- + +# Helm charts + +An example Backstage app can be deployed in Kubernetes using the +[Backstage Helm charts](https://github.com/backstage/backstage/tree/master/contrib/chart/backstage). + +First, choose a DNS name where Backstage will be hosted, and create a YAML file +for your custom configuration. + +```yaml +appConfig: + app: + baseUrl: https://backstage.mydomain.com + title: Backstage + backend: + baseUrl: https://backstage.mydomain.com + cors: + origin: https://backstage.mydomain.com + lighthouse: + baseUrl: https://backstage.mydomain.com/lighthouse-api + techdocs: + storageUrl: https://backstage.mydomain.com/api/techdocs/static/docs + requestUrl: https://backstage.mydomain.com/api/techdocs +``` + +Then use it to run: + +```bash +git clone https://github.com/backstage/backstage.git +cd contrib/chart/backstage +helm dependency update +helm install -f backstage-mydomain.yaml backstage . +``` + +This command will deploy the following pieces: + +- Backstage frontend +- Backstage backend with scaffolder and auth plugins +- (optional) a PostgreSQL instance +- lighthouse plugin +- ingress + +After a few minutes Backstage should be up and running in your cluster under the +DNS specified earlier. + +Make sure to create the appropriate DNS entry in your infrastructure. To find +the public IP address run: + +```bash +$ kubectl get ingress +NAME HOSTS ADDRESS PORTS AGE +backstage-ingress * 123.1.2.3 80 17m +``` + +> **NOTE**: this is not a production ready deployment. + +For more information on how to customize the deployment check the +[README](https://github.com/backstage/backstage/tree/master/contrib/chart/backstage/README.md). diff --git a/docs/getting-started/deployment-k8s.md b/docs/getting-started/deployment-k8s.md index ddcbd290c6..bc24894ed1 100644 --- a/docs/getting-started/deployment-k8s.md +++ b/docs/getting-started/deployment-k8s.md @@ -4,4 +4,10 @@ title: Kubernetes description: Documentation on Kubernetes and K8s Deployment --- -Coming soon! +Backstage itself provides tooling up to the point of building Docker images. +Beyond that point we do not have an opinionated way to deploy Backstage within +Kubernetes, as each cluster has its own unique set of tooling and patterns. + +We do provide examples to help you get started though. Check out +[this example](https://github.com/backstage/backstage/tree/master/contrib/kubernetes/plain_single_backend_deployment/) +for a basic single-deployment setup. diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md index 2cd9708e2a..cfd2e903d9 100644 --- a/docs/getting-started/deployment-other.md +++ b/docs/getting-started/deployment-other.md @@ -13,18 +13,37 @@ Run the following commands if you have Docker environment ```bash $ yarn install $ yarn docker-build -$ docker run --rm -it -p 7000:7000 -e NODE_ENV=development example-backend:latest +$ docker run --rm -it -p 7000:7000 -e APP_ENV=production -e NODE_ENV=development example-backend:latest ``` Then open http://localhost/ on your browser. -### Running with `docker-compose` +## Heroku -There is also a `docker-compose.yaml` that you can use to replace the previous -`docker run` command: +Deploying to Heroku is relatively easy following these steps. + +First, make sure you have the +[Heroku CLI installed](https://devcenter.heroku.com/articles/heroku-cli) and log +into it as well as login into Heroku's +[container registry](https://devcenter.heroku.com/articles/container-registry-and-runtime). ```bash -$ yarn install -$ yarn docker-build -$ docker-compose up +$ heroku login +$ heroku container:login ``` + +You _might_ also need to set your Heroku app's stack to `container`. + +```bash +$ heroku stack:set container -a +``` + +We can now build/push the Docker image to Heroku's container registry and +release it to the `web` worker. + +```bash +$ heroku container:push web -a +$ heroku container:release web -a +``` + +With that, you should have Backstage up and running! diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index aa9aa0bde1..c5275114ce 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -56,7 +56,7 @@ system resources and slow things down. ## Package Scripts There are many commands to be found in the root -[package.json](https://github.com/spotify/backstage/blob/master/package.json), +[package.json](https://github.com/backstage/backstage/blob/master/package.json), here are some useful ones: ```python @@ -86,7 +86,7 @@ yarn create-plugin # Create a new plugin ``` > See -> [package.json](https://github.com/spotify/backstage/blob/master/package.json) +> [package.json](https://github.com/backstage/backstage/blob/master/package.json) > for other yarn commands/options. ## Local configuration diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 4ca2f431a7..7fc80b625c 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -23,8 +23,8 @@ Requests towards this repo. Backstage provides the `@backstage/create-app` package to scaffold standalone instances of Backstage. You will need to have -[NodeJS](https://nodejs.org/en/download/) Active LTS Release installed -(currently v12), [yarn](https://classic.yarnpkg.com/en/docs/install) and +[Node.js](https://nodejs.org/en/download/) Active LTS Release installed +(currently v14), [Yarn](https://classic.yarnpkg.com/en/docs/install) and [Python](https://www.python.org/downloads/) (although you likely have it already). You will also need to have [Docker](https://docs.docker.com/engine/install/) installed to use some features @@ -38,13 +38,13 @@ npx @backstage/create-app ``` You will be taken through a wizard to create your app, and the output should -look something like this. You can read more about this process -[here](https://backstage.io/docs/getting-started/create-an-app). +look something like this. You can read more about this process in +[Create an app](https://backstage.io/docs/getting-started/create-an-app). ### Contributing to Backstage You can read more in our -[CONTRIBUTING](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md) +[CONTRIBUTING](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md) guide, which can help you get setup with a Backstage development environment. ### Next steps diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md deleted file mode 100644 index 3b11d2b8e1..0000000000 --- a/docs/getting-started/installation.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -id: installation -title: Installation -description: Documentation on Installation ---- - -Coming soon! diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md index 6e6c8b2733..a30a918fd0 100644 --- a/docs/getting-started/running-backstage-locally.md +++ b/docs/getting-started/running-backstage-locally.md @@ -8,7 +8,7 @@ description: Documentation on How to run Backstage Locally - Node.js -First make sure you are using NodeJS with an Active LTS Release, currently v12. +First make sure you are using Node.js with an Active LTS Release, currently v12. This is made easy with a version manager such as [nvm](https://github.com/nvm-sh/nvm) which allows for version switching. @@ -23,10 +23,10 @@ node --version > v12.18.3 ``` -- yarn +- Yarn Please refer to the -[installation instructions for yarn](https://classic.yarnpkg.com/en/docs/install/). +[installation instructions for Yarn](https://classic.yarnpkg.com/en/docs/install/). - Docker @@ -42,7 +42,7 @@ of GitHub and run an initial build. ```bash # Start from your local development folder -git clone --depth 1 git@github.com:spotify/backstage.git +git clone --depth 1 git@github.com:backstage/backstage.git cd backstage # Fetch our dependencies and run an initial build @@ -84,19 +84,19 @@ exploring. But you can also set up any of the available authentication methods. The easiest option will be GitHub. To setup GitHub authentication in Backstage, see -[these instructions](https://github.com/spotify/backstage/tree/master/plugins/auth-backend#github). +[these instructions](https://github.com/backstage/backstage/tree/master/plugins/auth-backend#github). --- 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) +[feature](https://github.com/backstage/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), +[plugin suggestions](https://github.com/backstage/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) +[bugs](https://github.com/backstage/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)! +[contribute](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md)! ## Creating a Plugin diff --git a/docs/openapi/definitions/auth.yaml b/docs/openapi/definitions/auth.yaml index cb736fd2a9..411ce69253 100644 --- a/docs/openapi/definitions/auth.yaml +++ b/docs/openapi/definitions/auth.yaml @@ -12,14 +12,14 @@ info: The API is supplied with a list of providers - such as `Google` or `Github` - and will add the endpoints described below to each of those providers. - Read more about [User Authentication and Authorization in Backstage](https://github.com/spotify/backstage/blob/master/docs/auth/overview.md). + Read more about [User Authentication and Authorization in Backstage](https://github.com/backstage/backstage/blob/master/docs/auth/overview.md). license: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html version: 0.1.1-alpha.8 externalDocs: description: Backstage official documentation - url: https://github.com/spotify/backstage/blob/master/docs/README.md + url: https://github.com/backstage/backstage/blob/master/docs/README.md servers: - url: http://localhost:7000/api/auth/ tags: diff --git a/docs/overview/adopting.md b/docs/overview/adopting.md index 422adfb288..0d404d5cb4 100644 --- a/docs/overview/adopting.md +++ b/docs/overview/adopting.md @@ -42,7 +42,7 @@ infra/platform teams and end-users: ![pop](../assets/pop.png) While anyone at your company can contribute to the platform, the vast majority -of work will be done by teams that also has internal engineers as their +of work will be done by teams that also have internal engineers as their customers. The central team should treat these _contributing teams_ as customers of the platform as well. @@ -51,9 +51,9 @@ customers. This is done primarily by building [plugins](../plugins/index.md). Contributing teams should themselves treat their plugins as, or part of, the products they maintain. -> Case study: Inside Spotify we have a team that owns our CI platform. They -> don't only maintain the pipelines and build servers, but also expose their -> product in Backstage through a plugin. Since they also +> Case study: Inside Spotify we have a team that owns our CI platform. They not +> only maintain the pipelines and build servers, but also expose their product +> in Backstage through a plugin. Since they also > [maintain their own API](../plugins/call-existing-api.md), they can improve > their product by iterating on API and UI in lockstep. Because the plugin > follows our [platform design guidelines](../dls/design.md) their customers get @@ -123,14 +123,14 @@ successful impact on your software development process: engineer is someone that is able to contribute to different domains of engineering. Teams with T-shaped people have fewer bottlenecks and can therefore deliver more consistently. Backstage makes it easier to be T-shaped - since tools and infrastructure is consistent between domains, and information + since tools and infrastructure are consistent between domains, and information is available centrally. - **eNPS** Surveys asking about how productive people feel, how easy it is to find information and overall satisfaction with internal tools. - **Fragmentation** _(Experimental)_ Backstage - [Software Templates](../features/software-templates/index.md) helps drive + [Software Templates](../features/software-templates/index.md) help drive standardization in your software ecosystem. By measuring the variance in technology between different software components it is possible to get a sense of the overall fragmentation in your ecosystem. Examples could include: diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index 8edf3e9e4c..d8069c8665 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -24,7 +24,7 @@ different ways. ## Overview The following diagram shows how Backstage might look when deployed inside a -company which uses the Tech Radar plugin, the Lighthouse plugin, the Circle CI +company which uses the Tech Radar plugin, the Lighthouse plugin, the CircleCI plugin and the service catalog. There are 3 main components in this architecture: @@ -39,7 +39,7 @@ this. ![The architecture of a basic Backstage application](../assets/architecture-overview/backstage-typical-architecture.png) -## The UI +## User Interface The UI is a thin, client-side wrapper around a set of plugins. It provides some core UI components and libraries for shared activities such as config @@ -48,14 +48,14 @@ management. [[live demo](https://backstage-demo.roadie.io/)] ![UI with different components highlighted](../assets/architecture-overview/core-vs-plugin-components-highlighted.png) Each plugin typically makes itself available in the UI on a dedicated URL. For -example, the lighthouse plugin is registered with the UI on `/lighthouse`. +example, the Lighthouse plugin is registered with the UI on `/lighthouse`. [[live demo](https://backstage-demo.roadie.io/lighthouse)] ![The lighthouse plugin UI](../assets/architecture-overview/lighthouse-plugin.png) -The Circle CI plugin is available on `/circleci`. +The CircleCI plugin is available on `/circleci`. -![Circle CI Plugin UI](../assets/architecture-overview/circle-ci.png) +![CircleCI Plugin UI](../assets/architecture-overview/circle-ci.png) ## Plugins and plugin backends @@ -63,20 +63,20 @@ Each plugin is a client side application which mounts itself on the UI. Plugins are written in TypeScript or JavaScript. They each live in their own directory in `backstage/plugins`. For example, the source code for the lighthouse plugin is available at -[backstage/plugins/lighthouse](https://github.com/spotify/backstage/tree/master/plugins/lighthouse). +[backstage/plugins/lighthouse](https://github.com/backstage/backstage/tree/master/plugins/lighthouse). ### Installing plugins Plugins are typically loaded by the UI in your Backstage applications `plugins.ts` file. For example, -[here](https://github.com/spotify/backstage/blob/master/packages/app/src/plugins.ts) +[here](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) is that file in the Backstage sample app. Plugins can be enabled, and passed configuration in `apis.ts`. For example, -[here](https://github.com/spotify/backstage/blob/master/packages/app/src/apis.ts) +[here](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts) is that file in the Backstage sample app. -This is how the lighthouse plugin would be enabled in a typical Backstage +This is how the Lighthouse plugin would be enabled in a typical Backstage application: ```tsx @@ -108,7 +108,7 @@ Architecturally, plugins can take three forms: #### Standalone plugins Standalone plugins run entirely in the browser. -[The tech radar plugin](https://backstage-demo.roadie.io/tech-radar), for +[The Tech Radar plugin](https://backstage-demo.roadie.io/tech-radar), for example, simply renders hard-coded information. It doesn't make any API requests to other services. @@ -124,9 +124,9 @@ simple. Service backed plugins make API requests to a service which is within the purview of the organisation running Backstage. -The lighthouse plugin, for example, makes requests to the +The Lighthouse plugin, for example, makes requests to the [lighthouse-audit-service](https://github.com/spotify/lighthouse-audit-service). -The lighthouse-audit-service is a microservice which runs a copy of Google's +The `lighthouse-audit-service` is a microservice which runs a copy of Google's [Lighthouse library](https://github.com/GoogleChrome/lighthouse/) and stores the results in a PostgreSQL database. @@ -144,43 +144,44 @@ Third-party backed plugins are similar to service backed plugins. The main difference is that the service which backs the plugin is hosted outside of the ecosystem of the company hosting Backstage. -The Circle CI plugin is an example of a third-party backed plugin. Circle CI is -a SaaS service which can be used without any knowledge of Backstage. It has an -API which a Backstage plugin consumes to display content. +The CircleCI plugin is an example of a third-party backed plugin. CircleCI is a +SaaS service which can be used without any knowledge of Backstage. It has an API +which a Backstage plugin consumes to display content. -Requests which go to Circle CI from the users browser are passed through a proxy +Requests going to CircleCI from the user's browser are passed through a proxy service that Backstage provides. Without this, the requests would be blocked by Cross Origin Resource Sharing policies which prevent a browser page served at [https://example.com](https://example.com) from serving resources hosted at https://circleci.com. -![CircleCi plugin talking to proxy talking to SaaS Circle CI](../assets/architecture-overview/circle-ci-plugin-architecture.png) +![CircleCI plugin talking to proxy talking to SaaS Circle CI](../assets/architecture-overview/circle-ci-plugin-architecture.png) ## Databases -As we have seen, both the lighthouse-audit-service and catalog-backend require a -database to work with. +As we have seen, both the `lighthouse-audit-service` and `catalog-backend` +require a database to work with. -At the time of writing, the lighthouse-audit-service requires PostgreSQL to work -with. The service catalog backend uses an in-memory Sqlite3 instance. This is a -development oriented setup and there are plans to support other databases in the -future. +The Backstage backend and its builtin plugins are based on the +[Knex](http://knexjs.org/) library, and set up a separate logical database per +plugin. This gives great isolation and lets them perform migrations and evolve +separate from each other. -To learn more about the future of databases and Backstage, see the following two -GitHub issues. - -[Knex + Plugins (Multiple vs Single Database) · Issue #1598 · spotify/backstage](https://github.com/spotify/backstage/issues/1598) - -[Update migrations to support postgres by dariddler · Pull Request #1527 · spotify/backstage](https://github.com/spotify/backstage/pull/1527#discussion_r450374145) +The Knex library supports a multitude of databases, but Backstage is at the time +of writing tested primarily against two of them: SQLite, which is mainly used as +an in-memory mock/test database, and PostgreSQL, which is the preferred +production database. Other databases such as the MySQL variants are reported to +work but +[aren't tested as fully](https://github.com/backstage/backstage/issues/2460) +yet. ## Containerization The example Backstage architecture shown above would Dockerize into three -separate docker images. +separate Docker images. 1. The frontend container 2. The backend container -3. The lighthouse audit service container +3. The Lighthouse audit service container ![Boxes around the architecture to indicate how it is containerised](../assets/architecture-overview/containerised.png) @@ -204,7 +205,7 @@ yarn run docker-build This will create a container called `example-backend`. The lighthouse-audit-service container is already publicly available in Docker -Hub and can be downloaded and ran with +Hub and can be downloaded and run with ```bash docker run spotify/lighthouse-audit-service:latest diff --git a/docs/overview/logos.md b/docs/overview/logos.md index 2a684420bc..4c0c345f36 100644 --- a/docs/overview/logos.md +++ b/docs/overview/logos.md @@ -8,7 +8,7 @@ description: Guidelines for how to use the Backstage logos and icons Guidelines for how to use the Backstage logo and icon can be found [here](/logo_assets/Backstage_Identity_Assets_Overview.pdf). The assets below are all in `.svg` format. Other formats are available in the -[repository](https://github.com/spotify/backstage/tree/master/microsite/static/logo_assets). +[repository](https://github.com/backstage/backstage/tree/master/microsite/static/logo_assets). ## Backstage logo diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index 1c90916fb5..71612cc5e4 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -9,8 +9,8 @@ description: Roadmap of Backstage Project > Backstage is currently under rapid development. This means that you can expect > APIs and features to evolve. It is also recommended that teams who adopt > Backstage today upgrade their installation as new -> [releases](https://github.com/spotify/backstage/releases) become available, as -> Backwards compatibility is not yet guaranteed. +> [releases](https://github.com/backstage/backstage/releases) become available, +> as Backwards compatibility is not yet guaranteed. ## Phases @@ -37,56 +37,39 @@ We have divided the project into three high-level _phases_: If you have questions about the roadmap or want to provide feedback, we would love to hear from you! Please create an -[Issue](https://github.com/spotify/backstage/issues/new/choose), ping us on +[Issue](https://github.com/backstage/backstage/issues/new/choose), ping us on [Discord](https://discord.gg/EBHEGzX) or reach out directly at -[alund@spotify.com](mailto:alund@spotify.com). +[backstage-interest@spotify.com](mailto:backstage-interest@spotify.com). Want to help out? Awesome ❤️ Head over to -[CONTRIBUTING](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md) +[CONTRIBUTING](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md) guidelines to get started. ### Ongoing work 🚧 -- **[Plugins for managing micro services end-2-end](https://github.com/spotify/backstage/milestone/14)** - +- **[Plugins for managing micro services end-2-end](https://github.com/backstage/backstage/milestone/14)** Out of the box Backstage will ship with a set of plugins (Overview, CI, API and Docs) that will demonstrate how a user can manage a micro service and follow a change all the way out in production. Completing this work will make it much easier to see how a plugin can be built that integrates with the Backstage Service Catalog. -- **[Kubernetes support](https://github.com/spotify/backstage/milestone/20)** - - Native support for Kubernetes, making it easier for developers to see and - manage their services running in k8s. +- **[Users and teams](https://github.com/backstage/backstage/issues/1807)** + Ownership is a central concept in Backstage. It should be easy to import your + existing organizational data, such as users and groups/teams, into Backstage. + A user the logs into Backstage should see software components owned by the + team(s) they are in. -- **[Helm charts](https://github.com/spotify/backstage/issues/2540)** - Provide - Helm charts for easy deployments of Backstage and its subsystems on - Kubernetes. - -- **[Backstage platform is stable](https://github.com/spotify/backstage/milestone/19)** - +- **[Backstage platform is stable](https://github.com/backstage/backstage/milestone/19)** - The platform APIs and features are stable and can be depended on for production use. After this plugins will require little to no maintenance. -- **Backstage Design System** - By providing design guidelines for common plugin - layouts together, rich set of reusable UI components - ([Storybook](https://backstage.io/storybook)) and Figma design resources. The - Design System will make it easy to design and build plugins that are - consistent across the platform -- supporting both developers and designers. +* **[Improved Kubernetes plugin](https://github.com/backstage/backstage/issues/2857)** - + Native support for Kubernetes, making it easier for developers to see and + manage their services running in k8s. -- Further improvements to platform documentation - -### Plugins - -Building and maintaining [plugins](https://backstage.io/plugins) is the work of -the entire Backstage community. - -A list of plugins that are in development is -[available here](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+label%3Aplugin+sort%3Areactions-%2B1-desc). -We strongly recommend to upvote 👍 plugins you are interested in. This helps us -and the community prioritize what plugins to build. - -Are you missing a plugin for your favorite tool? Please -[suggest a new one](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). -Chances are that someone will jump in and help build it. +- Further improvements to platform documentation. Examples include a Golden Path + for plugin development. ### Future work 🔮 @@ -94,27 +77,44 @@ Chances are that someone will jump in and help build it. deployment available publicly so that people can click around and get a feel for the product without having to install anything. -- **[Global search](https://github.com/spotify/backstage/issues/1499)** - Extend - the basic search available in the Backstage Service Catalog with a global - search experience. Long term this search solution should be extensible, making - it possible for you add custom search results. +- **[Global search](https://github.com/backstage/backstage/issues/1499)** - + Extend the basic search available in the Backstage Service Catalog with a + global search experience. Long term this search solution should be extensible, + making it possible for you add custom search results. -- **[[TechDocs V.2] Stabilization release](https://github.com/spotify/backstage/milestone/17)** - +- **[[TechDocs V.2] Stabilization release](https://github.com/backstage/backstage/milestone/17)** - Platform stability and compatibility improvements. - **Additional auth providers** - Backstage should work for most (all!) auth solutions. Since Backstage can be used by companies regardless of what cloud (or on prem) you are using we are especially keen to get auth support for - [AWS](https://github.com/spotify/backstage/issues/290), - [Azure](https://github.com/spotify/backstage/issues/348) and others. + [AWS](https://github.com/backstage/backstage/issues/290), + [Azure](https://github.com/backstage/backstage/issues/348) and others. -- **[Initial GraphQL API](https://github.com/spotify/backstage/milestone/13)** - +- **[Initial GraphQL API](https://github.com/backstage/backstage/milestone/13)** - A GraphQL API will open up the rich metadata provided by Backstage in a single query. Plugins can easily query this API as well as extend the model where needed. +### Plugins + +Building and maintaining [plugins](https://backstage.io/plugins) is the work of +the entire Backstage community. + +A list of plugins that are in development is +[available here](https://github.com/backstage/backstage/issues?q=is%3Aissue+is%3Aopen+label%3Aplugin+sort%3Areactions-%2B1-desc). +We strongly recommend to upvote 👍 plugins you are interested in. This helps us +and the community prioritize what plugins to build. + +Are you missing a plugin for your favorite tool? Please +[suggest a new one](https://github.com/backstage/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). +Chances are that someone will jump in and help build it. + ### Completed milestones ✅ +- [Kubernetes plugin - v1](https://github.com/backstage/backstage/tree/master/plugins/kubernetes) +- [Helm charts](https://github.com/backstage/backstage/tree/master/contrib/chart/backstage) +- [Backstage Design System 💅](https://backstage.io/blog/2020/09/30/backstage-design-system) - [Cost Insights plugin 💸](https://engineering.atspotify.com/2020/09/29/managing-clouds-from-the-ground-up-cost-engineering-at-spotify/) - [Donate Backstage to the CNCF 🎉](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox) - [TechDocs v1](https://backstage.io/blog/2020/09/08/announcing-tech-docs) @@ -123,11 +123,11 @@ Chances are that someone will jump in and help build it. - [Backstage Service Catalog (alpha)](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha) - [Backstage Software Templates (alpha)](https://backstage.io/blog/2020/08/05/announcing-backstage-software-templates) - [Make it possible to add custom auth providers](https://backstage.io/blog/2020/07/01/how-to-enable-authentication-in-backstage-using-passport) -- [TechDocs v0](https://github.com/spotify/backstage/milestone/15) +- [TechDocs v0](https://github.com/backstage/backstage/milestone/15) - CI plugins: CircleCI, Jenkins, GitHub Actions and TravisCI -- [Service API documentation](https://github.com/spotify/backstage/pull/1737) +- [Service API documentation](https://github.com/backstage/backstage/pull/1737) - Backstage Service Catalog can read from: GitHub, GitLab, - [Bitbucket](https://github.com/spotify/backstage/pull/1938) + [Bitbucket](https://github.com/backstage/backstage/pull/1938) - Support auth providers: Google, Okta, GitHub, GitLab, - [auth0](https://github.com/spotify/backstage/pull/1611), - [AWS](https://github.com/spotify/backstage/pull/1990) + [auth0](https://github.com/backstage/backstage/pull/1611), + [AWS](https://github.com/backstage/backstage/pull/1990) diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md new file mode 100644 index 0000000000..416cdcdd4a --- /dev/null +++ b/docs/overview/stability-index.md @@ -0,0 +1,384 @@ +--- +id: stability-index +title: Stability Index +description: + An overview of the commitment to stability for different parts of the + Backstage codebase. +--- + +## Overview + +The purpose of the Backstage Stability Index is to communicate the stability of +various parts of the project. It is tracked using a scoring system where a +higher score indicates a higher level of stability and is a commitment to +smoother transitions between breaking changes. Importantly, the Stability Index +does not supersede [semver](https://semver.org/), meaning we will still adhere +to semver and only do breaking changes in minor releases as long as we are on +`0.x`. + +Each package or section is assigned a stability score between 0 and 3, with each +point building on top of the previous one: + +- **0** - Breaking changes are noted in the changelog, and documentation is + updated. +- **1** - The changelog entry includes a clearly documented upgrade path, + providing guidance for how to migrate previous usage patterns to the new + version. +- **2** - Breaking changes always include a deprecation phase where both the old + and the new APIs can be used in parallel. This deprecation must have been + released for at least two weeks before the deprecated API is removed in a + minor version bump. +- **3** - The time limit for the deprecation is 3 months instead of two weeks. + +TL;DR: + +- **0** - There's a changelog entry. +- **1** - There's a migration guide. +- **2** - 2 weeks of deprecation. +- **3** - 3 months of deprecation. + +## Packages + +### [`example-app`](https://github.com/backstage/backstage/tree/master/packages/app/) + +This is the `packages/app` package, and it serves as an example as well as +utility for local development in the main Backstage repo. + +Stability: `N/A` + +### [`example-backend`](https://github.com/backstage/backstage/tree/master/packages/backend/) + +This is the `packages/backend` package, and it serves as an example as well as +utility for local development in the main Backstage repo. + +Stability: `N/A` + +### [`backend-common`](https://github.com/backstage/backstage/tree/master/packages/backend-common/) + +A collection of common helpers to be used by both backend plugins, and for +constructing backend packages. + +Stability: `1` + +### [`catalog-client`](https://github.com/backstage/backstage/tree/master/packages/catalog-client/) + +An HTTP client for interacting with the catalog backend. Usable both in frontend +and Backend. + +Stability: `0`. This is a very new addition and we have some immediate changes +planned. + +### [`catalog-model`](https://github.com/backstage/backstage/tree/master/packages/catalog-model/) + +Contains the core catalog model, and utilities for working with entities. Usable +both in frontend and Backend. + +Stability: `2`. The catalog model is evolving, but because of the broad usage we + +want to ensure some stability. + +### [`cli`](https://github.com/backstage/backstage/tree/master/packages/cli/) + +The main toolchain used for Backstage development. The interface that is +considered for stability are the various commands and options passed to those +commands, as well as the environment variables read by the CLI. The build output +may change over time and is not considered a breaking change unless it is likely +to affect external tooling. + +Stability: `2` + +### [`cli-common`](https://github.com/backstage/backstage/tree/master/packages/cli-common/) + +Lightweight utilities used by the various Backstage CLIs, not intended for +external use. + +Stability: `N/A` + +### [`config`](https://github.com/backstage/backstage/tree/master/packages/config/) + +Provides the logic and interfaces for reading static configuration. + +Stability: `2` + +### [`config-loader`](https://github.com/backstage/backstage/tree/master/packages/config-loader/) + +Used to load in static configuration, mainly for use by the CLI and +@backstage/backend-common. + +Stability: `1`. Mainly intended for internal use. + +### [`core`](https://github.com/backstage/backstage/tree/master/packages/core/) + +#### Section: React Components + +All of the React components exported from `src/components/` and `src/layout/` + +Stability: `1`. These components have not received a proper review of the API, +but we also want to ensure stability. + +#### Section: Plugin API + +The parts of the core API that are used by plugins, and the way plugins expose +functionality to apps and other plugins. Includes for example `createPlugin`, +`createRouteRef`, `createApiRef`. + +Stability: `2`. There are planned breaking changes around the way that plugins +expose features and do routing. We still commit to keeping a short deprecation +period so that plugins outside of the main repo have time to migrate. + +#### Section: App API + +The APIs used exclusively in the app, such as `createApp` and the system icons. + +Stability: `2` + +#### Section: Utility API Definitions + +The type declarations of the core Utility APIs. + +Stability: `2`. Changes to the Utility API type declarations need time to +propagate. + +#### Section: Utility API Implementations + +The interfaces and default implementations for various Utility APIs, such as +ErrorApi, IdentityApi, the auth APIs, etc. + +Stability: `1`. Most changes to the core utility APIs will not lead to +widespread breaking changes since most apps rely on the default implementations. + +### [`core-api`](https://github.com/backstage/backstage/tree/master/packages/core-api/) + +The non-visual parts of @backstage/core. Everything in this packages is +re-exported from @backstage/core, and this package should not be used directly. + +Stability: See @backstage/core + +### [`create-app`](https://github.com/backstage/backstage/tree/master/packages/create-app/) + +The CLI used to scaffold new Backstage projects. + +Stability: `2` + +### [`dev-utils`](https://github.com/backstage/backstage/tree/master/packages/dev-utils/) + +Provides utilities for developing plugins in isolation. + +Stability: `0`. This package is largely broken and needs updates. + +### [`docgen`](https://github.com/backstage/backstage/tree/master/packages/docgen/) + +Internal CLI utility for generating API Documentation. + +Stability: `N/A` + +### [`e2e-test`](https://github.com/backstage/backstage/tree/master/packages/e2e-test/) + +Internal CLI utility for running e2e tests. + +Stability: `N/A` + +### [`storybook`](https://github.com/backstage/backstage/tree/master/packages/storybook/) + +Internal storybook build for publishing stories to +https://backstage.io/storybook + +Stability: `N/A` + +### [`test-utils`](https://github.com/backstage/backstage/tree/master/packages/test-utils/) + +Utilities for writing tests for Backstage plugins and apps. + +Stability: `2` + +### [`test-utils-core`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core/) + +Internal testing utilities that are separated out for usage in +@backstage/core-api. All exports are re-exported by @backstage/test-utils. This +package should not be depended on directly. + +Stability: See @backstage/test-utils + +### [`theme`](https://github.com/backstage/backstage/tree/master/packages/theme/) + +The core Backstage MUI theme along with customization utilities. + +#### Section: TypeScript + +This is the TypeScript API exported by the theme package. + +Stability: `2` + +#### Section: Visual Theme + +The visual theme exported by the theme packages, where for example changing a +color could be considered a breaking change. + +Stability: `1` + +## Plugins + +Plugins are rarely marked as stable as the `@backstage/core` plugin API is under +heavy development. + +Many backend plugins are split into "REST API" and "TypeScript Interface" +sections. The "TypeScript Interface" refers to the API used to integrate the +plugin into the backend. + +Any plugin that is not listed below is untracked and can generally be considered +unstable with a score of `0`. Open a Pull Request if you want your plugin to be +added! + +### [`api-docs`](https://github.com/backstage/backstage/tree/master/plugins/api-docs/) + +Components to discover and display API entities as an extension to the catalog +plugin. + +Stability: `0` + +### [`app-backend`](https://github.com/backstage/backstage/tree/master/plugins/app-backend/) + +A backend plugin that can be used to serve the frontend app and inject +configuration. + +Stability: `2` + +### [`auth-backend`](https://github.com/backstage/backstage/tree/master/plugins/auth-backend/) + +A backend plugin that implements the backend portion of the various +authentication flows used in Backstage. + +#### Section: REST API + +Stability: `2` + +#### Section: TypeScript Interface + +Stability: `1` + +### [`catalog`](https://github.com/backstage/backstage/tree/master/plugins/catalog/) + +The frontend plugin for the catalog, with the table and building blocks for the +entity pages. + +Stability: `1`. We're planning some work to overhaul how entity pages are +constructed. + +### [`catalog-backend`](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend/) + +The backend API for the catalog, also exposes the processing subsystem for +customization of the catalog. Powers the @backstage/plugin-catalog frontend +plugin. + +#### Section: REST API + +Stability: `1`. There are plans to remove and rework some endpoints. + +#### Section: TypeScript Interface + +Stability: `1`. There are plans to rework parts of the Processor interface. + +### [`catalog-graphql`](https://github.com/backstage/backstage/tree/master/plugins/catalog-graphql/) + +Provides the catalog schema and resolvers for the graphql backend. + +Stability: `0`. Under heavy development and subject to change. + +### [`explore`](https://github.com/backstage/backstage/tree/master/plugins/explore/) + +A frontend plugin that introduces the concept of exploring internal and external +tooling in an organization. + +Stability: `0`. Only an example at the moment and not customizable. + +### [`graphiql`](https://github.com/backstage/backstage/tree/master/plugins/graphiql/) + +Integrates GraphiQL as a tool to browse GraphQL API endpoints inside Backstage. + +Stability: `1` + +### [`graphql`](https://github.com/backstage/backstage/tree/master/plugins/graphql/) + +A backend plugin that provides + +Stability: `0`. Under heavy development and subject to change. + +### [`kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes/) + +The frontend component of the Kubernetes plugin, used to browse and visualize +Kubernetes resources. + +Stability: `1`. + +### [`kubernetes-backend`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend/) + +The backend component of the Kubernetes plugin, used to fetch Kubernetes +resources from clusters and associate them with entities in the Catalog. + +Stability: `1`. + +### [`proxy-backend`](https://github.com/backstage/backstage/tree/master/plugins/proxy-backend/) + +A backend plugin used to set up proxying to other endpoints based on static +configuration. + +Stability: `1` + +### [`register-component`](https://github.com/backstage/backstage/tree/master/plugins/register-component/) + +A frontend plugin that allows the user to register entity locations in the +catalog. + +Stability: `0`. This plugin is likely to be replaced by a generic entity import +plugin instead. + +### [`scaffolder`](https://github.com/backstage/backstage/tree/master/plugins/scaffolder/) + +The frontend scaffolder plugin where one can browse templates and initiate +scaffolding jobs. + +Stability: `1` + +### [`scaffolder-backend`](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend/) + +The backend scaffolder plugin that provides an implementation for templates in +the catalog. + +Stability: `1`. There is planned work to rework the scaffolder in +https://github.com/backstage/backstage/issues/2771. + +### [`tech-radar`](https://github.com/backstage/backstage/tree/master/plugins/tech-radar/) + +Visualize the your company's official guidelines of different areas of software +development. + +Stability: `0` + +### [`techdocs`](https://github.com/backstage/backstage/tree/master/plugins/techdocs/) + +The frontend component of the TechDocs plugin, used to browse technical +documentation of entities. + +Stability: `1` + +### [`techdocs-backend`](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/) + +The backend component of the TechDocs plugin, used to transform and serve +TechDocs. + +Stability: `0` + +### [`user-settings`](https://github.com/backstage/backstage/tree/master/plugins/user-settings/) + +A frontend plugin that provides a page where the user can tweak various +settings. + +Stability: `1` + +### [`welcome`](https://github.com/backstage/backstage/tree/master/plugins/welcome/) + +A plugin that can be used to welcome the user to Backstage. + +Stability: `0`. This used to be the start page for the example app, but has been +replaced by the catalog plugin. It is still viewable at `/welcome` but may be +removed. diff --git a/docs/overview/support.md b/docs/overview/support.md deleted file mode 100644 index 12bc7cfaf9..0000000000 --- a/docs/overview/support.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -id: support -title: Support and community -description: Support and Community Details and Links ---- - -- [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the - project -- [Good First Issues](https://github.com/spotify/backstage/contribute) - Start - here if you want to contribute -- [RFCs](https://github.com/spotify/backstage/labels/rfc) - Help shape the - technical direction -- [FAQ](../FAQ.md) - Frequently Asked Questions -- [Code of Conduct](../../CODE_OF_CONDUCT.md) - This is how we roll -- [Blog](https://backstage.io/blog/) - Announcements and updates -- [Newsletter](https://mailchi.mp/spotify/backstage-community) - Subscribe to - our email newsletter -- Give us a star ⭐️ - If you are using Backstage or think it is an interesting - project, we would love a star ❤️ diff --git a/docs/overview/vision.md b/docs/overview/vision.md index df75a4f0a6..c17e2b17ba 100644 --- a/docs/overview/vision.md +++ b/docs/overview/vision.md @@ -19,5 +19,5 @@ We are working on making Backstage the trusted standard toolbox (read: UX layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product -vision, we'd love to talk. You can email me directly: -[alund@spotify.com](mailto:alund@spotify.com). +vision, we'd love to talk. You can email us directly: +[backstage-interest@spotify.com](mailto:backstage-interest@spotify.com). diff --git a/docs/overview/what-is-backstage.md b/docs/overview/what-is-backstage.md index 3d41c714ee..5439b838bd 100644 --- a/docs/overview/what-is-backstage.md +++ b/docs/overview/what-is-backstage.md @@ -30,7 +30,7 @@ Out of the box, Backstage includes: code" approach - Plus, a growing ecosystem of - [open source plugins](https://github.com/spotify/backstage/tree/master/plugins) + [open source plugins](https://github.com/backstage/backstage/tree/master/plugins) that further expand Backstage’s customizability and functionality ## Backstage and the CNCF diff --git a/docs/plugins/add-to-marketplace.md b/docs/plugins/add-to-marketplace.md index 59cca07bff..790512e6aa 100644 --- a/docs/plugins/add-to-marketplace.md +++ b/docs/plugins/add-to-marketplace.md @@ -8,7 +8,7 @@ description: Documentation on Adding Plugin to Marketplace To add a new plugin to the [plugin marketplace](https://backstage.io/plugins) create a file in -[`microsite/data/plugins`](https://github.com/spotify/backstage/tree/master/microsite/data/plugins) +[`microsite/data/plugins`](https://github.com/backstage/backstage/tree/master/microsite/data/plugins) with your plugin's information. Example: ```yaml diff --git a/docs/plugins/call-existing-api.md b/docs/plugins/call-existing-api.md index 14ea4c1f5a..75e054d2b9 100644 --- a/docs/plugins/call-existing-api.md +++ b/docs/plugins/call-existing-api.md @@ -88,7 +88,7 @@ The proxy is powered by the `http-proxy-middleware` package. See [Proxying](proxying.md) for a full description of its configuration options. Internally at Spotify, the proxy option has been the overwhelmingly most popular -choice for plugin makers. Since we have DNS based service discovery in place and +choice for plugin makers. Since we have DNS-based service discovery in place and a microservices framework that made it trivial to expose plain HTTP, it has been a matter of just adding a few lines of Backstage config to get the benefit of being easily and robustly reachable from users' web browsers as well. @@ -137,7 +137,7 @@ router.use('/summary', async (req, res) => { ``` For a more detailed example, see -[the lighthouse plugin](https://github.com/spotify/backstage/tree/master/plugins/lighthouse) +[the lighthouse plugin](https://github.com/backstage/backstage/tree/master/plugins/lighthouse) that stores some state in a database and adds new capabilities to the underlying API. diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 8836c1cd42..dcef3f995c 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -22,7 +22,7 @@ To create a plugin, follow the steps outlined [here](create-a-plugin.md). If you start developing a plugin that you aim to release as open source, we suggest that you create a -[new Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). +[new Issue](https://github.com/backstage/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development. You can also use this process if you have an idea for a good plugin but you hope diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index 3d9aa90447..f55b4d9229 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -57,6 +57,15 @@ is also possible to limit the forwarded HTTP methods with the configuration `allowedMethods`, for example `allowedMethods: ['GET']` to enforce read-only access. +By default, the proxy will only forward safe HTTP request headers to the target. +Those are based on the headers that are considered safe for CORS and includes +headers like `content-type` or `last-modified`, as well as all headers that are +set by the proxy. If the proxy should forward other headers like +`authorization`, this must be enabled by the `allowedHeaders` config, for +example `allowedHeaders: ['Authorization']`. This should help to not +accidentally forward confidential headers (`cookie`, `X-Auth-Request-User`) to +third-parties. + If the value is a string, it is assumed to correspond to: ```yaml diff --git a/docs/plugins/publishing.md b/docs/plugins/publishing.md index fbeab6340c..7eab61891e 100644 --- a/docs/plugins/publishing.md +++ b/docs/plugins/publishing.md @@ -7,7 +7,7 @@ description: Documentation on Publishing NPM packages ## NPM NPM packages are published through CI/CD in the -[.github/workflows/master.yml](https://github.com/spotify/backstage/blob/master/.github/workflows/master.yml) +[.github/workflows/master.yml](https://github.com/backstage/backstage/blob/master/.github/workflows/master.yml) workflow. Every commit that is merged to master will be checked for new versions of all public packages, and any new versions will automatically be published to NPM. @@ -56,5 +56,3 @@ $ git reset --hard master $ yarn release $ git push --force ``` - -[Back to Docs](../README.md) diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index ee0a27cde2..3f631a9a46 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -73,7 +73,7 @@ export const plugin = createPlugin({ ``` This is where the plugin is created and where it hooks into the app by declaring -what component should be shown on what url. See reference docs for +what component should be shown on what URL. See reference docs for [createPlugin](../reference/createPlugin.md) or [router](../reference/createPlugin-router.md). @@ -101,11 +101,11 @@ Backstage CLI. ## Talking to the outside world -If your plugin needs to communicate with services outside the backstage +If your plugin needs to communicate with services outside the Backstage environment you will probably face challenges like CORS policies and/or backend-side authorization. To smooth this process out you can use proxy - -either the one you already have (like nginx/haproxy/etc) or the proxy-backend -plugin that we provide for the backstage backend. -[Read more](https://github.com/spotify/backstage/blob/master/plugins/proxy-backend/README.md) +either the one you already have (like Nginx, HAProxy, etc.) or the proxy-backend +plugin that we provide for the Backstage backend. +[Read more](https://github.com/backstage/backstage/blob/master/plugins/proxy-backend/README.md) [Back to Getting Started](../README.md) diff --git a/docs/plugins/testing.md b/docs/plugins/testing.md index c6e1599786..e60b5fa8b7 100644 --- a/docs/plugins/testing.md +++ b/docs/plugins/testing.md @@ -248,15 +248,15 @@ Testing an API involves verifying four things: ### Mocking API Calls -[Mocking in jest](https://facebook.github.io/jest/docs/en/mock-functions.html) +[Mocking in Jest](https://facebook.github.io/jest/docs/en/mock-functions.html) involves wrapping existing functions (like an API call function) with an alternative. For example: -**./Api.js** +**`./MyApi.js`** -``` +```js export { fetchSomethingFromServer: () => { // Live production call to a URI. Must be avoided during testing! @@ -265,9 +265,9 @@ export { }; ``` -**./\_\_mocks\_\_/Api.js** +**`./\_\_mocks\_\_/MyApi.js`** -``` +```js export { fetchSomethingFromServer: () => { // Simulate a production call, but avoid jest and just use a promise @@ -276,16 +276,16 @@ export { } ``` -**./Api.test.js** +**`./MyApi.test.js`** -``` +```js /* eslint-disable import/first */ jest.mock('./MyApi'); // Instruct Jest to swap all future imports of './MyApi.js' to './__mocks__/MyApi.js' import MyApi from './MyApi'; // Will actually return the contents of the file in the __mocks__ folder now -it ('loads data', (done) => { +it('loads data', done => { MyApi.fetchSomethingFromServer().then(result => { expect(result).toBe('some result object simulating server data here'); done(); diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index ebb8a6b503..bcea80e26b 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -8,12 +8,6 @@ The `featureFlags` object passed to the `register` function makes it possible for plugins to register Feature Flags in Backstage for users to opt into. You can use this to split out logic in your code for manual A/B testing, etc. -```typescript -export type FeatureFlagsHooks = { - register(name: FeatureFlagName): void; -}; -``` - Here's a code sample: ```typescript @@ -21,8 +15,7 @@ import { createPlugin } from '@backstage/core'; export default createPlugin({ id: 'welcome', - register({ router, featureFlags }) { - // router.registerRoute('/', Component); + register({ featureFlags }) { featureFlags.register('enable-example-feature'); }, }); @@ -30,41 +23,22 @@ export default createPlugin({ ## Using with useApi -To use it, you'll first need to register the `FeatureFlags` API via -`ApiRegistry` in your `apis.ts` in your App: - -```tsx -import { - ApiHolder, - ApiRegistry, - featureFlagsApiRef, - FeatureFlags, -} from '@backstage/core'; - -const builder = ApiRegistry.builder(); -builder.add(featureFlagsApiRef, new FeatureFlags()); - -export default builder.build() as ApiHolder; -``` - -Then, later, you can directly use it via `useApi`: +To inspect the state of a feature flag inside your plugin, you can use the +`FeatureFlagsApi`, accessed via the `featureFlagsApiRef`. For example: ```tsx import React, { FC } from 'react'; import { Button } from '@material-ui/core'; -import { FeatureFlagState, featureFlagsApiRef, useApi } from '@backstage/core'; +import { featureFlagsApiRef, useApi } from '@backstage/core'; -const ExampleButton: FC<{}> = () => { - const flags = useApi(featureFlagsApiRef).getFlags(); - - const handleClick = () => { - flags.set('enable-example-feature', FeatureFlagState.On); - }; +const ExamplePage = () => { + const featureFlags = useApi(featureFlagsApiRef); return ( - +
+ + { featureFlags.isActive('enable-example-feature') && } +
); }; ``` diff --git a/docs/reference/utility-apis/AlertApi.md b/docs/reference/utility-apis/AlertApi.md index 6219f520f5..4116e964ae 100644 --- a/docs/reference/utility-apis/AlertApi.md +++ b/docs/reference/utility-apis/AlertApi.md @@ -1,7 +1,7 @@ # AlertApi The AlertApi type is defined at -[packages/core-api/src/apis/definitions/AlertApi.ts:29](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/AlertApi.ts#L29). +[packages/core-api/src/apis/definitions/AlertApi.ts:29](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AlertApi.ts#L29). The following Utility API implements this type: [alertApiRef](./README.md#alert) @@ -38,7 +38,7 @@ export type AlertMessage = { Defined at -[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/AlertApi.ts#L19). +[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AlertApi.ts#L19). Referenced by: [post](#post), [alert\$](#alert). @@ -67,13 +67,13 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). Referenced by: [alert\$](#alert). ### Observer -This file contains non-react related core types used through Backstage. +This file contains non-react related core types used throughout Backstage. Observer interface for consuming an Observer, see TC39. @@ -86,7 +86,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -109,6 +109,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/AppThemeApi.md b/docs/reference/utility-apis/AppThemeApi.md index eb35eb38ef..0f5c095825 100644 --- a/docs/reference/utility-apis/AppThemeApi.md +++ b/docs/reference/utility-apis/AppThemeApi.md @@ -1,7 +1,7 @@ # AppThemeApi The AppThemeApi type is defined at -[packages/core-api/src/apis/definitions/AppThemeApi.ts:50](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/AppThemeApi.ts#L50). +[packages/core-api/src/apis/definitions/AppThemeApi.ts:56](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L56). The following Utility API implements this type: [appThemeApiRef](./README.md#apptheme) @@ -72,11 +72,16 @@ export type AppTheme = { * The specialized MaterialUI theme instance. */ theme: BackstageTheme; + + /** + * An Icon for the theme mode setting. + */ + icon?: React.ReactElement<SvgIconProps>; } Defined at -[packages/core-api/src/apis/definitions/AppThemeApi.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/AppThemeApi.ts#L24). +[packages/core-api/src/apis/definitions/AppThemeApi.ts:25](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L25). Referenced by: [getInstalledThemes](#getinstalledthemes). @@ -87,7 +92,7 @@ export type BackstagePalette = Palette & Palette Defined at -[packages/theme/src/types.ts:70](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/theme/src/types.ts#L70). +[packages/theme/src/types.ts:74](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L74). Referenced by: [BackstageTheme](#backstagetheme). @@ -96,11 +101,13 @@ Referenced by: [BackstageTheme](#backstagetheme).
 export interface BackstageTheme extends Theme {
   palette: BackstagePalette;
+  page: PageTheme;
+  getPageTheme: ({ themeId }: PageThemeSelector) => PageTheme;
 }
 
Defined at -[packages/theme/src/types.ts:73](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/theme/src/types.ts#L73). +[packages/theme/src/types.ts:81](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L81). Referenced by: [AppTheme](#apptheme). @@ -129,13 +136,13 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). Referenced by: [activeThemeId\$](#activethemeid). ### Observer -This file contains non-react related core types used through Backstage. +This file contains non-react related core types used throughout Backstage. Observer interface for consuming an Observer, see TC39. @@ -148,10 +155,38 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). +### PageTheme + +
+export type PageTheme = {
+  colors: string[];
+  shape: string;
+  backgroundImage: string;
+}
+
+ +Defined at +[packages/theme/src/types.ts:103](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L103). + +Referenced by: [BackstageTheme](#backstagetheme). + +### PageThemeSelector + +
+export type PageThemeSelector = {
+  themeId: string;
+}
+
+ +Defined at +[packages/theme/src/types.ts:77](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L77). + +Referenced by: [BackstageTheme](#backstagetheme). + ### PaletteAdditions
@@ -181,6 +216,8 @@ type PaletteAdditions = {
   navigation: {
     background: string;
     indicator: string;
+    color: string;
+    selectedColor: string;
   };
   tabbar: {
     indicator: string;
@@ -199,12 +236,14 @@ type PaletteAdditions = {
   banner: {
     info: string;
     error: string;
+    text: string;
+    link: string;
   };
 }
 
Defined at -[packages/theme/src/types.ts:23](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/theme/src/types.ts#L23). +[packages/theme/src/types.ts:23](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/theme/src/types.ts#L23). Referenced by: [BackstagePalette](#backstagepalette). @@ -227,6 +266,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md index 529b73c576..dafdb7d55f 100644 --- a/docs/reference/utility-apis/BackstageIdentityApi.md +++ b/docs/reference/utility-apis/BackstageIdentityApi.md @@ -1,7 +1,7 @@ # BackstageIdentityApi The BackstageIdentityApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:134](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L134). +[packages/core-api/src/apis/definitions/auth.ts:134](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L134). The following Utility APIs implement this type: @@ -19,6 +19,8 @@ The following Utility APIs implement this type: - [oktaAuthApiRef](./README.md#oktaauth) +- [samlAuthApiRef](./README.md#samlauth) + ## Members ### getBackstageIdentity() @@ -68,7 +70,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getBackstageIdentity](#getbackstageidentity). @@ -89,6 +91,6 @@ export type BackstageIdentity = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:147](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L147). +[packages/core-api/src/apis/definitions/auth.ts:147](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L147). Referenced by: [getBackstageIdentity](#getbackstageidentity). diff --git a/docs/reference/utility-apis/Config.md b/docs/reference/utility-apis/Config.md index b54e122c03..2ae047a700 100644 --- a/docs/reference/utility-apis/Config.md +++ b/docs/reference/utility-apis/Config.md @@ -1,7 +1,7 @@ # Config The Config type is defined at -[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L32). The following Utility API implements this type: [configApiRef](./README.md#config) @@ -140,7 +140,7 @@ export type Config = { Defined at -[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L32). Referenced by: [getConfig](#getconfig), [getOptionalConfig](#getoptionalconfig), [getConfigArray](#getconfigarray), @@ -153,7 +153,7 @@ export type JsonArray = JsonValue[] Defined at -[packages/config/src/types.ts:18](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/config/src/types.ts#L18). +[packages/config/src/types.ts:18](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L18). Referenced by: [JsonValue](#jsonvalue). @@ -164,7 +164,7 @@ export type JsonObject = { [key in string]?: JsonValue Defined at -[packages/config/src/types.ts:17](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/config/src/types.ts#L17). +[packages/config/src/types.ts:17](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L17). Referenced by: [JsonValue](#jsonvalue). @@ -181,7 +181,7 @@ export type JsonValue = Defined at -[packages/config/src/types.ts:19](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/config/src/types.ts#L19). +[packages/config/src/types.ts:19](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/config/src/types.ts#L19). Referenced by: [get](#get), [getOptional](#getoptional), [JsonObject](#jsonobject), [JsonArray](#jsonarray), [Config](#config). diff --git a/docs/reference/utility-apis/DiscoveryApi.md b/docs/reference/utility-apis/DiscoveryApi.md index 24371c1729..ac278fdd97 100644 --- a/docs/reference/utility-apis/DiscoveryApi.md +++ b/docs/reference/utility-apis/DiscoveryApi.md @@ -1,7 +1,7 @@ # DiscoveryApi The DiscoveryApi type is defined at -[packages/core-api/src/apis/definitions/DiscoveryApi.ts:30](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L30). +[packages/core-api/src/apis/definitions/DiscoveryApi.ts:30](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L30). The following Utility API implements this type: [discoveryApiRef](./README.md#discovery) diff --git a/docs/reference/utility-apis/ErrorApi.md b/docs/reference/utility-apis/ErrorApi.md index 762d2016d5..93f4f9cd48 100644 --- a/docs/reference/utility-apis/ErrorApi.md +++ b/docs/reference/utility-apis/ErrorApi.md @@ -1,7 +1,7 @@ # ErrorApi The ErrorApi type is defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). +[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). The following Utility API implements this type: [errorApiRef](./README.md#error) @@ -41,7 +41,7 @@ type Error = { Defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). +[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). Referenced by: [post](#post), [error\$](#error). @@ -58,7 +58,7 @@ export type ErrorContext = { Defined at -[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). +[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). Referenced by: [post](#post), [error\$](#error). @@ -87,13 +87,13 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). Referenced by: [error\$](#error). ### Observer -This file contains non-react related core types used through Backstage. +This file contains non-react related core types used throughout Backstage. Observer interface for consuming an Observer, see TC39. @@ -106,7 +106,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -129,6 +129,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/FeatureFlagsApi.md b/docs/reference/utility-apis/FeatureFlagsApi.md index 8fbcb794fb..529d2ac5dc 100644 --- a/docs/reference/utility-apis/FeatureFlagsApi.md +++ b/docs/reference/utility-apis/FeatureFlagsApi.md @@ -1,27 +1,20 @@ # FeatureFlagsApi The FeatureFlagsApi type is defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:41](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L41). +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:60](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L60). The following Utility API implements this type: [featureFlagsApiRef](./README.md#featureflags) ## Members -### registeredFeatureFlags +### registerFlag() -Store a list of registered feature flags. +Registers a new feature flag. Once a feature flag has been registered it can be +toggled by users, and read back to enable or disable features.
-registeredFeatureFlags: FeatureFlagsRegistryItem[]
-
- -### getFlags() - -Get a list of all feature flags from the current user. - -
-getFlags(): UserFlags
+registerFlag(flag: FeatureFlag): void
 
### getRegisteredFlags() @@ -29,5 +22,92 @@ getFlags(): UserFlags Get a list of all registered flags.
-getRegisteredFlags(): FeatureFlagsRegistry
+getRegisteredFlags(): FeatureFlag[]
 
+ +### isActive() + +Whether the feature flag with the given name is currently activated for the +user. + +
+isActive(name: string): boolean
+
+ +### save() + +Save the user's choice of feature flag states. + +
+save(options: FeatureFlagsSaveOptions): void
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### FeatureFlag + +The feature flags API is used to toggle functionality to users across plugins +and Backstage. + +Plugins can use this API to register feature flags that they have available for +users to enable/disable, and this API will centralize the current user's state +of which feature flags they would like to enable. + +This is ideal for Backstage plugins, as well as your own App, to trial +incomplete or unstable upcoming features. Although there will be a common +interface for users to enable and disable feature flags, this API acts as +another way to enable/disable. + +
+export type FeatureFlag = {
+  name: string;
+  pluginId: string;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:31](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L31). + +Referenced by: [registerFlag](#registerflag), +[getRegisteredFlags](#getregisteredflags). + +### FeatureFlagState + +
+export enum FeatureFlagState {
+  None = 0,
+  Active = 1,
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:36](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L36). + +Referenced by: [FeatureFlagsSaveOptions](#featureflagssaveoptions). + +### FeatureFlagsSaveOptions + +Options to use when saving feature flags. + +
+export type FeatureFlagsSaveOptions = {
+  /**
+   * The new feature flag states to save.
+   */
+  states: Record<string, FeatureFlagState>;
+
+  /**
+   * Whether the saves states should be merged into the existing ones, or replace them.
+   *
+   * Defaults to false.
+   */
+  merge?: boolean;
+}
+
+ +Defined at +[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:44](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L44). + +Referenced by: [save](#save). diff --git a/docs/reference/utility-apis/IdentityApi.md b/docs/reference/utility-apis/IdentityApi.md index 5ee5c582b6..a88aaf0b8a 100644 --- a/docs/reference/utility-apis/IdentityApi.md +++ b/docs/reference/utility-apis/IdentityApi.md @@ -1,7 +1,7 @@ # IdentityApi The IdentityApi type is defined at -[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). +[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). The following Utility API implements this type: [identityApiRef](./README.md#identity) @@ -76,6 +76,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L162). +[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L162). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/OAuthApi.md b/docs/reference/utility-apis/OAuthApi.md index a489db76c5..79b55812ef 100644 --- a/docs/reference/utility-apis/OAuthApi.md +++ b/docs/reference/utility-apis/OAuthApi.md @@ -1,7 +1,7 @@ # OAuthApi The OAuthApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L67). +[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L67). The following Utility APIs implement this type: @@ -82,7 +82,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getAccessToken](#getaccesstoken). @@ -108,6 +108,6 @@ export type OAuthScope = string | string[] Defined at -[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L38). +[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L38). Referenced by: [getAccessToken](#getaccesstoken). diff --git a/docs/reference/utility-apis/OAuthRequestApi.md b/docs/reference/utility-apis/OAuthRequestApi.md index 1328aabeec..f1c2311ce9 100644 --- a/docs/reference/utility-apis/OAuthRequestApi.md +++ b/docs/reference/utility-apis/OAuthRequestApi.md @@ -1,7 +1,7 @@ # OAuthRequestApi The OAuthRequestApi type is defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). The following Utility API implements this type: [oauthRequestApiRef](./README.md#oauthrequest) @@ -73,7 +73,7 @@ export type AuthProvider = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). Referenced by: [AuthRequesterOptions](#authrequesteroptions), [PendingAuthRequest](#pendingauthrequest). @@ -97,7 +97,7 @@ export type AuthRequester<AuthResponse> = ( Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). Referenced by: [createAuthRequester](#createauthrequester). @@ -122,7 +122,7 @@ export type AuthRequesterOptions<AuthResponse> = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). Referenced by: [createAuthRequester](#createauthrequester). @@ -151,13 +151,13 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). Referenced by: [authRequest\$](#authrequest). ### Observer -This file contains non-react related core types used through Backstage. +This file contains non-react related core types used throughout Backstage. Observer interface for consuming an Observer, see TC39. @@ -170,7 +170,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -205,7 +205,7 @@ export type PendingAuthRequest = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). +[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). Referenced by: [authRequest\$](#authrequest). @@ -228,6 +228,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/OpenIdConnectApi.md b/docs/reference/utility-apis/OpenIdConnectApi.md index efd79593d6..eaea530708 100644 --- a/docs/reference/utility-apis/OpenIdConnectApi.md +++ b/docs/reference/utility-apis/OpenIdConnectApi.md @@ -1,7 +1,7 @@ # OpenIdConnectApi The OpenIdConnectApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:99](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L99). +[packages/core-api/src/apis/definitions/auth.ts:99](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L99). The following Utility APIs implement this type: @@ -66,6 +66,6 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getIdToken](#getidtoken). diff --git a/docs/reference/utility-apis/ProfileInfoApi.md b/docs/reference/utility-apis/ProfileInfoApi.md index 402b5ba504..76d04045e8 100644 --- a/docs/reference/utility-apis/ProfileInfoApi.md +++ b/docs/reference/utility-apis/ProfileInfoApi.md @@ -1,7 +1,7 @@ # ProfileInfoApi The ProfileInfoApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:117](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L117). +[packages/core-api/src/apis/definitions/auth.ts:117](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L117). The following Utility APIs implement this type: @@ -19,6 +19,8 @@ The following Utility APIs implement this type: - [oktaAuthApiRef](./README.md#oktaauth) +- [samlAuthApiRef](./README.md#samlauth) + ## Members ### getProfile() @@ -65,7 +67,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L40). +[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L40). Referenced by: [getProfile](#getprofile). @@ -93,6 +95,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L162). +[packages/core-api/src/apis/definitions/auth.ts:162](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L162). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/README.md b/docs/reference/utility-apis/README.md index 71931a5d5a..1ef5b6197e 100644 --- a/docs/reference/utility-apis/README.md +++ b/docs/reference/utility-apis/README.md @@ -3,7 +3,7 @@ The following is a list of all Utility APIs defined by `@backstage/core`. They are available to use by plugins and components, and can be accessed using the `useApi` hook, also provided by `@backstage/core`. For more information, see -https://github.com/spotify/backstage/blob/master/docs/api/utility-apis.md. +https://github.com/backstage/backstage/blob/master/docs/api/utility-apis.md. ### alert @@ -12,7 +12,7 @@ Used to report alerts and forward them to the app Implemented type: [AlertApi](./AlertApi.md) ApiRef: -[alertApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/AlertApi.ts#L41) +[alertApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AlertApi.ts#L41) ### appTheme @@ -21,7 +21,7 @@ API Used to configure the app theme, and enumerate options Implemented type: [AppThemeApi](./AppThemeApi.md) ApiRef: -[appThemeApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/AppThemeApi.ts#L74) +[appThemeApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L80) ### auth0Auth @@ -32,7 +32,7 @@ Implemented types: [OpenIdConnectApi](./OpenIdConnectApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[auth0AuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L275) +[auth0AuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L275) ### config @@ -41,7 +41,7 @@ Used to access runtime configuration Implemented type: [Config](./Config.md) ApiRef: -[configApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/ConfigApi.ts#L22) +[configApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ConfigApi.ts#L22) ### discovery @@ -50,7 +50,7 @@ Provides service discovery of backend plugins Implemented type: [DiscoveryApi](./DiscoveryApi.md) ApiRef: -[discoveryApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L44) +[discoveryApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L44) ### error @@ -59,7 +59,7 @@ Used to report errors and forward them to the app Implemented type: [ErrorApi](./ErrorApi.md) ApiRef: -[errorApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) +[errorApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) ### featureFlags @@ -68,7 +68,7 @@ Used to toggle functionality in features across Backstage Implemented type: [FeatureFlagsApi](./FeatureFlagsApi.md) ApiRef: -[featureFlagsApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L58) +[featureFlagsApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L83) ### githubAuth @@ -79,7 +79,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[githubAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L232) +[githubAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L232) ### gitlabAuth @@ -90,7 +90,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[gitlabAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L262) +[gitlabAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L262) ### googleAuth @@ -102,7 +102,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[googleAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L215) +[googleAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L215) ### identity @@ -111,7 +111,7 @@ Provides access to the identity of the signed in user Implemented type: [IdentityApi](./IdentityApi.md) ApiRef: -[identityApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/IdentityApi.ts#L54) +[identityApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/IdentityApi.ts#L54) ### microsoftAuth @@ -123,7 +123,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[microsoftAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L289) +[microsoftAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L289) ### oauth2 @@ -135,7 +135,7 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[oauth2ApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L303) +[oauth2ApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L303) ### oauthRequest @@ -144,7 +144,7 @@ An API for implementing unified OAuth flows in Backstage Implemented type: [OAuthRequestApi](./OAuthRequestApi.md) ApiRef: -[oauthRequestApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) +[oauthRequestApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) ### oktaAuth @@ -156,7 +156,17 @@ Implemented types: [OAuthApi](./OAuthApi.md), [BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[oktaAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L245) +[oktaAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L245) + +### samlAuth + +Example of how to use SAML custom provider + +Implemented types: [ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) + +ApiRef: +[samlAuthApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L317) ### storage @@ -165,4 +175,4 @@ Provides the ability to store data which is unique to the user Implemented type: [StorageApi](./StorageApi.md) ApiRef: -[storageApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/StorageApi.ts#L68) +[storageApiRef](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L68) diff --git a/docs/reference/utility-apis/SessionApi.md b/docs/reference/utility-apis/SessionApi.md index e7a9e58c59..7d271558d6 100644 --- a/docs/reference/utility-apis/SessionApi.md +++ b/docs/reference/utility-apis/SessionApi.md @@ -1,7 +1,7 @@ # SessionApi The SessionApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:190](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L190). +[packages/core-api/src/apis/definitions/auth.ts:190](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L190). The following Utility APIs implement this type: @@ -19,6 +19,8 @@ The following Utility APIs implement this type: - [oktaAuthApiRef](./README.md#oktaauth) +- [samlAuthApiRef](./README.md#samlauth) + ## Members ### signIn() @@ -75,13 +77,13 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). Referenced by: [sessionState\$](#sessionstate). ### Observer -This file contains non-react related core types used through Backstage. +This file contains non-react related core types used throughout Backstage. Observer interface for consuming an Observer, see TC39. @@ -94,7 +96,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -110,7 +112,7 @@ export enum SessionState { Defined at -[packages/core-api/src/apis/definitions/auth.ts:182](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L182). +[packages/core-api/src/apis/definitions/auth.ts:182](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/auth.ts#L182). Referenced by: [sessionState\$](#sessionstate). @@ -133,6 +135,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/SessionStateApi.md b/docs/reference/utility-apis/SessionStateApi.md index 81f25aa349..a8a4c3bc3d 100644 --- a/docs/reference/utility-apis/SessionStateApi.md +++ b/docs/reference/utility-apis/SessionStateApi.md @@ -1,7 +1,7 @@ # SessionStateApi The SessionStateApi type is defined at -[packages/core-api/src/apis/definitions/auth.ts:201](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L201). +[packages/core-api/src/apis/definitions/auth.ts:201](https://github.com/backstage/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L201). The following Utility APIs implement this type: @@ -56,7 +56,7 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). Referenced by: [sessionState\$](#sessionstate). @@ -75,7 +75,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -91,7 +91,7 @@ export enum SessionState { Defined at -[packages/core-api/src/apis/definitions/auth.ts:192](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L192). +[packages/core-api/src/apis/definitions/auth.ts:192](https://github.com/backstage/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L192). Referenced by: [sessionState\$](#sessionstate). @@ -114,6 +114,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/reference/utility-apis/StorageApi.md b/docs/reference/utility-apis/StorageApi.md index e7d5131ff9..6c5595d3d8 100644 --- a/docs/reference/utility-apis/StorageApi.md +++ b/docs/reference/utility-apis/StorageApi.md @@ -1,7 +1,7 @@ # StorageApi The StorageApi type is defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). The following Utility API implements this type: [storageApiRef](./README.md#storage) @@ -79,13 +79,13 @@ export type Observable<T> = { Defined at -[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L53). Referenced by: [observe\$](#observe), [StorageApi](#storageapi). ### Observer -This file contains non-react related core types used through Backstage. +This file contains non-react related core types used throughout Backstage. Observer interface for consuming an Observer, see TC39. @@ -98,7 +98,7 @@ export type Observer<T> = { Defined at -[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -144,7 +144,7 @@ export interface StorageApi { Defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). Referenced by: [forBucket](#forbucket). @@ -158,7 +158,7 @@ export type StorageValueChange<T = any> = { Defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/StorageApi.ts#L21). +[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/apis/definitions/StorageApi.ts#L21). Referenced by: [observe\$](#observe), [StorageApi](#storageapi). @@ -181,6 +181,6 @@ export type Subscription = { Defined at -[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/backstage/backstage/blob/ca535f2f66c3a4980c80f4b1a049dfd07569010e/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/support/project-structure.md b/docs/support/project-structure.md new file mode 100644 index 0000000000..c25e087e57 --- /dev/null +++ b/docs/support/project-structure.md @@ -0,0 +1,238 @@ +--- +id: project-structure +title: Backstage Project Structure +description: + Introduction to files and folders in the Backstage Project repository +--- + +Backstage is a complex project, and the GitHub repository contains many +different files and folders. This document aims to clarify what purpose of those +files and folders are. + +## General purpose files and folders + +In the project root, there are a set of files and folders which are not part of +the project as such, and may or may not be familiar to someone looking through +the code. + +- [`.changeset/`](https://github.com/backstage/backstage/tree/master/.changeset) - + This folder contains files outlining which changes occurred in the project + since the last release. These files are added manually, but managed by + [changesets](https://github.com/atlassian/changesets) and will be removed at + every new release. They are essentially building-blocks of a CHANGELOG. + +- [`.github/`](https://github.com/backstage/backstage/tree/master/.github) - + Standard GitHub folder. It contains - amongst other things - our workflow + definitions and templates. Worth noting is the + [styles](https://github.com/backstage/backstage/tree/master/.github/styles) + folder which is used for a markdown spellchecker. + +- [`.yarn/`](https://github.com/backstage/backstage/tree/master/.yarn) - + Backstage ships with it's own `yarn` implementation. This allows us to have + better control over our `yarn.lock` file and hopefully avoid problems due to + yarn versioning differences. + +- [`docker/`](https://github.com/backstage/backstage/tree/master/docker) - Files + related to our root Dockerfile. We are planning to refactor this, so expect + this folder to be moved in the future. + +- [`contrib/`](https://github.com/backstage/backstage/tree/master/contrib) - + Collection of examples or resources provided by the community. We really + appreciate contributions in here and encourage them being kept up to date. + +- [`docs/`](https://github.com/backstage/backstage/tree/master/docs) - This is + where we keep all of our documentation Markdown files. These ends up on + http://backstage.io/docs. Just keep in mind that changes to the + [`sidebars.json`](https://github.com/backstage/backstage/blob/master/microsite/sidebars.json) + file may be needed as sections are added/removed. + +- [`.editorconfig`](https://github.com/backstage/backstage/tree/master/.editorconfig) - + A configuration file used by most common code editors. + +- [`.imgbotconfig`](https://github.com/backstage/backstage/tree/master/.imgbotconfig) - + Configuration for a [bot](https://imgbot.net/) + +## Monorepo packages + +Every folder in both `packages/` and `plugins/` is within our monorepo setup, as +defined in +[`package.json`](https://github.com/backstage/backstage/blob/master/package.json): + +```json + "workspaces": { + "packages": [ + "packages/*", + "plugins/*" + ] + }, +``` + +Let's look at them individually. + +### `packages/` + +These are all the packages that we use within the project. [Plugins](#plugins) +are separated out into their own folder, see further down. + +- [`app/`](https://github.com/backstage/backstage/tree/master/packages/app) - + This is our take on how an App could look like, bringing together a set of + packages and plugins into a working Backstage App. This is not a published + package, and the main goals are to provide a demo of what an App could look + like and to enable local development. + +- [`backend/`](https://github.com/backstage/backstage/tree/master/packages/backend) - + Every standalone Backstage project will have both an `app` _and_ a `backend` + package. The `backend` uses plugins to construct a working backend that the + frontend (`app`) can use. + +- [`backend-common/`](https://github.com/backstage/backstage/tree/master/packages/backend-common) - + There are no "core" packages in the backend. Instead we have `backend-common` + which contains helper middleware and other utils. + +- [`catalog-client`](https://github.com/backstage/backstage/tree/master/packages/catalog-client) - + An isomorphic client to interact with the Software Catalog. Backend plugins + can use the package directly. Frontend plugins can use the client by using + `@backstage/plugin-catalog` in combination with `useApi` and the + `catalogApiRef`. + +- [`catalog-model/`](https://github.com/backstage/backstage/tree/master/packages/catalog-model) - + You can consider this to be a library for working with the catalog of sorts. + It contains the definition of an + [Entity](https://backstage.io/docs/features/software-catalog/references#docsNav), + as well as validation and other logic related to it. This package can be used + in both the frontend and the backend. + +- [`cli/`](https://github.com/backstage/backstage/tree/master/packages/cli) - + One of the biggest packages in our project, the `cli` is used to build, serve, + diff, create-plugins and more. In the early days of this project, we started + out with calling tools directly - such as `eslint` - through `package.json`. + But as it was tricky to have a good development experience around that when we + change named tooling, we opted for wrapping those in our own cli. That way + everything looks the same in `package.json`. Much like + [react-scripts](https://github.com/facebook/create-react-app/tree/master/packages/react-scripts). + +- [`cli-common/`](https://github.com/backstage/backstage/tree/master/packages/cli-common) - + This package mainly handles path resolving. It is a separate package to reduce + bugs in + [cli](https://github.com/backstage/backstage/tree/master/packages/cli). We + also want as few dependencies as possible to reduce download time when running + the cli which is another reason this is a separate package. + +- [`config/`](https://github.com/backstage/backstage/tree/master/packages/config) - + The way we read configuration data. This package can take a bunch of config + objects and merge them together. + [app-config.yaml](https://github.com/backstage/backstage/blob/master/app-config.yaml) + is an example of an config object. + +- [`config-loader/`](https://github.com/backstage/backstage/tree/master/packages/config-loader) - + This package is used to read config objects. It does not know how to merge, + but only reads files and passes them on to the config. As this part is only + used by the backend, we chose to separate `config` and `config-loader` into + two different packages. + +- [`core/`](https://github.com/backstage/backstage/tree/master/packages/core) - + This package contains our visual React components, some of which you can find + in + [plugin examples](https://backstage.io/storybook/?path=/story/plugins-examples--plugin-with-data). + Apart from that it re-exports everything from [`core-api`] so that users only + need to rely on one package. + +- [`core-api/`](https://github.com/backstage/backstage/tree/master/packages/core-api) - + This package contains APIs and definitions of such. It is it's own package + because we needed to split our `test-utils` package. It's an implementation + detail that we try to hide from our users, and no one should have to depend on + it directly. + +- [`test-utils/`](https://github.com/backstage/backstage/tree/master/packages/test-utils) - + This package contains specific testing facilities used when testing + `core-api`. + +- [`test-utils-core/`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core) - + This package contains more general purpose testing facilities for testing a + Backstage App. + +- [`create-app/`](https://github.com/backstage/backstage/tree/master/packages/create-app) - + An CLI to specifically scaffold a new Backstage App. It does so by using a + [template](https://github.com/backstage/backstage/tree/master/packages/create-app/templates/default-app). + +- [`dev-utils/`](https://github.com/backstage/backstage/tree/master/packages/dev-utils) - + Helps you setup a plugin for isolated development so that it can be served + separately. + +- [`docgen/`](https://github.com/backstage/backstage/tree/master/packages/docgen) - + Uses the + [Typescript Compiler API](https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API) + to read out definitions and generate documentation for it. + +- [`e2e-test/`](https://github.com/backstage/backstage/tree/master/packages/e2e-test) - + Another CLI that can be run to try out what would happen if you built all the + packages, publish them, created a new app, and the run it. CI uses this for + e2e-tests. + +- [`storybook/`](https://github.com/backstage/backstage/tree/master/packages/storybook) - + This folder contains only the storybook config. Stories are within the core + package. The Backstage Storybook is found + [here](https://backstage.io/storybook) + +- [`test-utils-core/`](https://github.com/backstage/backstage/tree/master/packages/test-utils-core) + +- [`test-utils/`](https://github.com/backstage/backstage/tree/master/packages/test-utils) + +- [`theme/`](https://github.com/backstage/backstage/tree/master/packages/theme) - + Holds the Backstage Theme. + +### `plugins/` + +Most of the functionality of a Backstage App comes from plugins. Even core +features can be plugins, take the +[catalog](https://github.com/backstage/backstage/tree/master/plugins/catalog) as +an example. + +We can categorize plugins into three different types; **Frontend**, **Backend** +and **GraphQL**. We differentiate these types of plugins when we name them, with +a dash-suffix. `-backend` means it’s a backend plugin and so on. + +One reason for splitting a plugin is because of to it's dependencies. Another +reason is for clear separation of concerns. + +Take a look at our [Plugin Gallery](https://backstage.io/plugins) or browse +through the +[`plugins/`](https://github.com/backstage/backstage/tree/master/plugins) folder. + +## Packages outside of the monorepo + +For convenience we include packages in our project that are not part of our +monorepo setup. + +- [`microsite/`](https://github.com/backstage/backstage/blob/master/microsite) - + This folder contains the source code for backstage.io. It is built with + [Docusaurus](https://docusaurus.io/). This folder is not part of the monorepo + due to dependency reasons. Look at the + [README](https://github.com/backstage/backstage/blob/master/microsite/README.md) + for instructions on how to run it locally. + +## Root files specifically used by the `app` + +These files are kept in the root of the project mostly by historical reasons. +Some of these files may be subject to be moved out of the root sometime in the +future. + +- [`.npmrc`](https://github.com/backstage/backstage/tree/master/.npmrc) - It's + common for companies to have their own npm registry, this files makes sure + that this folder use the public registry. + +- [`.vale.ini`](https://github.com/backstage/backstage/tree/master/.vale.ini) - + [Spell checker](https://github.com/errata-ai/vale) for Markdown files. + +- [`.yarnrc`](https://github.com/backstage/backstage/tree/master/.yarnrc) - + Enforces "our" version of Yarn. + +- [`app-config.yaml`](https://github.com/backstage/backstage/tree/master/app-config.yaml) - + Configuration for the app, both frontend and backend. + +- [`catalog-info.yaml`](https://github.com/backstage/backstage/tree/master/catalog-info.yaml) - + Description of Backstage in the Backstage Entity format. + +- [`lerna.json`](https://github.com/backstage/backstage/tree/master/lerna.json) - + [Lerna](https://github.com/lerna/lerna) monorepo config. We are using + `yarn workspaces`, so this will only be used for executing scripts. diff --git a/docs/support/support.md b/docs/support/support.md new file mode 100644 index 0000000000..49075366af --- /dev/null +++ b/docs/support/support.md @@ -0,0 +1,20 @@ +--- +id: support +title: Support and community +description: Support and Community Details and Links +--- + +- [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the + project. +- [Good First Issues](https://github.com/backstage/backstage/contribute) - Start + here if you want to contribute. +- [RFCs](https://github.com/backstage/backstage/labels/rfc) - Help shape the + technical direction by reviewing _Request for Comments_ issues. +- [FAQ](../FAQ.md) - Frequently Asked Questions. +- [Code of Conduct](https://github.com/backstage/backstage/blob/master/CODE_OF_CONDUCT.md) - + This is how we roll. +- [Blog](https://backstage.io/blog/) - Announcements and updates. +- [Newsletter](https://mailchi.mp/spotify/backstage-community) - Subscribe to + our email newsletter. +- Give us a star ⭐️ - If you are using Backstage or think it is an interesting + project, we would love a star! ❤️ diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md index 908d7fc61b..664d4b77d3 100644 --- a/docs/tutorials/journey.md +++ b/docs/tutorials/journey.md @@ -179,7 +179,7 @@ const onSave = async () => { ``` 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 +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. @@ -239,12 +239,12 @@ 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. +couple of other plugins have 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 suggests a new well-known metadata @@ -264,14 +264,9 @@ 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/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md index 8f6b35617b..1dcca9d7e0 100644 --- a/docs/tutorials/quickstart-app-auth.md +++ b/docs/tutorials/quickstart-app-auth.md @@ -7,11 +7,11 @@ title: Monorepo App Setup With Authentication
-> This document takes you through setting up a backstage app that runs in your +> This document takes you through setting up a Backstage app that runs in your > own environment. It starts with a skeleton install and verifying of the > monorepo's functionality. Next, GitHub authentication is added and tested. > -> This document assumes you have NodeJS 12 active along with Yarn and Python. +> This document assumes you have Node.js 12 active along with Yarn and Python. > Please note, that at the time of this writing, the current version is > 0.1.1-alpha.21. This guide can still be used with future versions, just, > verify as you go. If you run into issues, you can compare your setup with mine diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 9f571b0c9a..216ff9659f 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -20,7 +20,7 @@ title: Adding Custom Plugin to Existing Monorepo App > functionality, extend the Sidebar to make our life easy. Finally, we add > custom code to display GitHub repository information. > -> This document assumes you have NodeJS 12 active along with Yarn and Python. +> This document assumes you have Node.js 12 active along with Yarn and Python. > Please note, that at the time of this writing, the current version is > 0.1.1-alpha.21. This guide can still be used with future versions, just, > verify as you go. If you run into issues, you can compare your setup with mine @@ -120,10 +120,10 @@ If everything is saved, you should see your name, id, and email on the github-playground page. Our data accessed is synchronous. So we just grab and go. -https://github.com/spotify/backstage/tree/master/contrib +https://github.com/backstage/backstage/tree/master/contrib 6. Here is the entire file for reference - [ExampleComponent.tsx](https://github.com/spotify/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md) + [ExampleComponent.tsx](https://github.com/backstage/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md) # The Wipe @@ -161,7 +161,7 @@ export default ExampleFetchComponent; # The Graph Model -GitHub has a graphql API available for interacting. Let's start by adding our +GitHub has a GraphQL API available for interacting. Let's start by adding our basic repository query 1. Add the query const statement outside ExampleFetchComponent @@ -302,7 +302,7 @@ return ( 8. After saving that, and given we don't have any errors, you should see a table with basic information on your repositories. 9. Here is the entire file for reference - [ExampleFetchComponent.tsx](https://github.com/spotify/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md) + [ExampleFetchComponent.tsx](https://github.com/backstage/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md) 10. We finished! You should see your own GitHub repository's information displayed in a basic table. If you run into issues, you can compare the repo that backs this document, diff --git a/docs/verify-links.js b/docs/verify-links.js deleted file mode 100755 index 9bf8a67ad8..0000000000 --- a/docs/verify-links.js +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env node -/* - * 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. - */ - -const { resolve: resolvePath, dirname } = require('path'); -const fs = require('fs-extra'); -const recursive = require('recursive-readdir'); - -const projectRoot = resolvePath(__dirname, '..'); - -async function verifyUrl(basePath, url) { - // Avoid having absolute URL links within docs/, so that links work on the site - if ( - url.match( - /https:\/\/github.com\/spotify\/backstage\/(tree|blob)\/master\/docs\//, - ) && - basePath.match(/^(?:docs|microsite)\//) - ) { - return { url, basePath, problem: 'absolute' }; - } - - url = url.replace(/#.*$/, ''); - url = url.replace( - /https:\/\/github.com\/spotify\/backstage\/(tree|blob)\/master/, - '', - ); - if (!url) { - return; - } - - // Only verify existence of local files for now, so skip anything with a schema - if (url.match(/[a-z]+:/)) { - return; - } - - let path = ''; - - if (url.startsWith('/')) { - if (url.startsWith('/docs/') && basePath.match(/^(?:docs|microsite)\//)) { - return { url, basePath, problem: 'not-relative' }; - } - - const staticPath = resolvePath(projectRoot, 'microsite/static', `.${url}`); - if (await fs.pathExists(staticPath)) { - return; - } - - path = resolvePath(projectRoot, `.${url}`); - } else { - path = resolvePath(dirname(resolvePath(projectRoot, basePath)), url); - } - - const exists = await fs.pathExists(path); - if (!exists) { - return { url, basePath, problem: 'missing' }; - } - - return; -} - -async function verifyFile(filePath) { - const content = await fs.readFile(filePath, 'utf8'); - const mdLinks = content.match(/\[.+?\]\(.+?\)/g) || []; - const badUrls = []; - - for (const mdLink of mdLinks) { - const url = mdLink.match(/\[.+\]\((.+)\)/)[1].trim(); - const badUrl = await verifyUrl(filePath, url); - if (badUrl) { - badUrls.push(badUrl); - } - } - - return badUrls; -} - -async function main() { - process.chdir(projectRoot); - - const files = await recursive('.', [ - 'node_modules', - 'dist', - 'bin', - 'microsite', - ]); - const mdFiles = files.filter(f => f.endsWith('.md')); - const badUrls = []; - - for (const mdFile of mdFiles) { - const badFileUrls = await verifyFile(mdFile); - badUrls.push(...badFileUrls); - } - - if (badUrls.length) { - console.log(`Found ${badUrls.length} bad links within repo`); - for (const { url, basePath, problem } of badUrls) { - if (problem === 'missing') { - console.error( - `Unable to reach ${url} from root or microsite/static/, linked from ${basePath}`, - ); - } else if (problem === 'not-relative') { - console.error('Links to /docs/ must be relative'); - console.error(` From: ${basePath}`); - console.error(` To: ${url}`); - } else if (problem === 'absolute') { - console.error(`Link to docs/ should be replaced by a relative URL`); - console.error(` From: ${basePath}`); - console.error(` To: ${url}`); - } - } - process.exit(1); - } -} - -main().catch(error => { - console.error(error.stack); - process.exit(1); -}); diff --git a/lerna.json b/lerna.json index 7d514abb97..dd2dd884eb 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "packages": ["packages/*", "plugins/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.1-alpha.24" + "version": "0.1.1" } diff --git a/microsite/blog/2020-03-16-announcing-backstage.md b/microsite/blog/2020-03-16-announcing-backstage.md index 3911debf08..5824b76a57 100644 --- a/microsite/blog/2020-03-16-announcing-backstage.md +++ b/microsite/blog/2020-03-16-announcing-backstage.md @@ -41,4 +41,4 @@ We are envisioning three phases of the project and we have already begun work on - **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is different. By fostering a vibrant community of contributors we hope to provide an ecosystem of Open Source plugins/integrations that allows you to pick the tools that match your stack. -Our vision for Backstage is for it to become the trusted standard toolbox (read: UI layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product vision, we'd love to talk. You can email me directly: [alund@spotify.com](mailto:alund@spotify.com). +Our vision for Backstage is for it to become the trusted standard toolbox (read: UI layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product vision, we'd love to talk. You can email us directly: [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com). diff --git a/microsite/blog/2020-03-18-what-is-backstage.md b/microsite/blog/2020-03-18-what-is-backstage.md index 8b0a0fcbe6..f4f62e2cf7 100644 --- a/microsite/blog/2020-03-18-what-is-backstage.md +++ b/microsite/blog/2020-03-18-what-is-backstage.md @@ -81,8 +81,8 @@ Similar to how Backstage ties together all of Spotify’s infrastructure, our am ## What’s next? -We are envisioning [three phases](https://github.com/spotify/backstage/milestones) of the project (so far), and we have already begun work on various aspects of these phases. The best way to track the work and see where you can jump in and help out is: +We are envisioning [three phases](https://github.com/backstage/backstage/milestones) of the project (so far), and we have already begun work on various aspects of these phases. The best way to track the work and see where you can jump in and help out is: -https://github.com/spotify/backstage/milestones +https://github.com/backstage/backstage/milestones -Want to discuss the project or need support? Join us on [Discord](https://discord.gg/MUpMjP2) or reach out on [alund@spotify.com](mailto:alund@spotify.com). +Want to discuss the project or need support? Join us on [Discord](https://discord.gg/MUpMjP2) or reach out on [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com). diff --git a/microsite/blog/2020-04-06-lighthouse-plugin.md b/microsite/blog/2020-04-06-lighthouse-plugin.md index 2d5c7b96b7..b8fd68e783 100644 --- a/microsite/blog/2020-04-06-lighthouse-plugin.md +++ b/microsite/blog/2020-04-06-lighthouse-plugin.md @@ -33,7 +33,7 @@ Trigger an audit directly from Backstage, or trigger audits programmatically wit ## Using Lighthouse in Backstage -To learn how you can enable Lighthouse auditing within Backstage, head over to the [README](https://github.com/spotify/backstage/tree/master/plugins/lighthouse) for the plugin to get started. +To learn how you can enable Lighthouse auditing within Backstage, head over to the [README](https://github.com/backstage/backstage/tree/master/plugins/lighthouse) for the plugin to get started. ## A personal note diff --git a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md index dff567eaf3..85f40dd9ea 100644 --- a/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md +++ b/microsite/blog/2020-04-30-how-to-quickly-set-up-backstage.md @@ -132,4 +132,4 @@ If you are developing a plugin that might be useful for others, consider releasi ## Ready to get started? -Head over to GitHub and check out the [project](https://github.com/spotify/backstage) or download our [CLI](https://www.npmjs.com/package/@backstage/cli). If you have more questions, join us on [Discord](https://discord.gg/MUpMjP2) or [create an issue](https://github.com/spotify/backstage/issues/new/choose). +Head over to GitHub and check out the [project](https://github.com/backstage/backstage) or download our [CLI](https://www.npmjs.com/package/@backstage/cli). If you have more questions, join us on [Discord](https://discord.gg/MUpMjP2) or [create an issue](https://github.com/backstage/backstage/issues/new/choose). diff --git a/microsite/blog/2020-05-14-tech-radar-plugin.md b/microsite/blog/2020-05-14-tech-radar-plugin.md index d78d5d67fa..80c1eb8b5a 100644 --- a/microsite/blog/2020-05-14-tech-radar-plugin.md +++ b/microsite/blog/2020-05-14-tech-radar-plugin.md @@ -34,11 +34,11 @@ To learn about how you can bring the Tech Radar to your Backstage installation, I want to thank both the Backstage team and Spotify. Firstly, I’ve been working with our internal version of Backstage for over a year, and the developer experience since open sourcing has been even more of a joy to work with. Secondly, the 10% hack time that Spotify generously provides to all engineers enabled me to open source the Tech Radar plugin. -Since open sourcing it, the community has shown great interest in yet another powerful use case of Backstage. There was also an enthusiastic open source contributor who volunteered to migrate the plugin to TypeScript and React Hooks [in just 29 minutes](https://github.com/spotify/backstage/issues/661) of opening the issue! +Since open sourcing it, the community has shown great interest in yet another powerful use case of Backstage. There was also an enthusiastic open source contributor who volunteered to migrate the plugin to TypeScript and React Hooks [in just 29 minutes](https://github.com/backstage/backstage/issues/661) of opening the issue! I can’t wait to see how others benefit from the Tech Radar in their organizations! [lighthouse website audits]: https://backstage.io/blog/2020/04/06/lighthouse-plugin -[tech radar plugin]: https://github.com/spotify/backstage/tree/master/plugins/tech-radar +[tech radar plugin]: https://github.com/backstage/backstage/tree/master/plugins/tech-radar [thoughtworks]: https://www.thoughtworks.com/radar [zalando]: https://opensource.zalando.com/tech-radar/ diff --git a/microsite/blog/2020-05-22-phase-2-service-catalog.md b/microsite/blog/2020-05-22-phase-2-service-catalog.md index 682965d2dd..103750dcc6 100644 --- a/microsite/blog/2020-05-22-phase-2-service-catalog.md +++ b/microsite/blog/2020-05-22-phase-2-service-catalog.md @@ -7,7 +7,7 @@ authorImageURL: https://pbs.twimg.com/profile_images/121166861/6919c047c0d0edaac **TL;DR** Thanks to the help from the Backstage community, we’ve made excellent progress and are now moving into Phase 2 of Backstage — building out a Service Catalog and the surrounding systems that will help unify the tools you use to manage your software. -We released the open source version of Backstage a little less than two months ago, and have been thrilled to see so many people jumping in and contributing to the project in its early stages. We’re excited to see what the community can build together as we progress through [each phase of Backstage](https://github.com/spotify/backstage#project-roadmap). +We released the open source version of Backstage a little less than two months ago, and have been thrilled to see so many people jumping in and contributing to the project in its early stages. We’re excited to see what the community can build together as we progress through [each phase of Backstage](https://github.com/backstage/backstage#project-roadmap). ![img](assets/20-05-20/Service_Catalog_MVP.png) @@ -15,7 +15,7 @@ We released the open source version of Backstage a little less than two months a ## Progress so far -Phase 1 was all about building an extensible frontend platform, enabling teams to start creating a single, consistent UI layer for your internal infrastructure and tools in the form of [plugins](https://github.com/spotify/backstage/labels/plugin). In fact, thanks to our amazing (30+) [contributors](https://github.com/spotify/backstage/graphs/contributors), we were able to complete most of Phase 1 earlier than expected. 🎉 +Phase 1 was all about building an extensible frontend platform, enabling teams to start creating a single, consistent UI layer for your internal infrastructure and tools in the form of [plugins](https://github.com/backstage/backstage/labels/plugin). In fact, thanks to our amazing (30+) [contributors](https://github.com/backstage/backstage/graphs/contributors), we were able to complete most of Phase 1 earlier than expected. 🎉 Today, we are happy to announce that we are shifting our focus to Phase 2! @@ -27,7 +27,7 @@ Quote from [Platform Nuts & Bolts: Extendable Data Models](https://www.kislayver Entities, or what we refer to as “components” in Backstage, represent all software, including services, websites, libraries, data pipelines, and so forth. The focus of Phase 2 will be on adding an entity model in Backstage that makes it easy for engineers to create and manage the software components they own. -With the ability to create a plethora of components in Backstage, how does one keep track of all the software in the ecosystem? Therein lies the highlight feature of Phase 2: the [Service Catalog](https://github.com/spotify/backstage/milestone/4). The service catalog — or software catalog — is a centralized system that keeps track of ownership and metadata about all software in your ecosystem. The catalog is built around the concept of [metadata yaml files](/docs/architecture-decisions/adr002-default-catalog-file-format.md) stored together with the code, which are then harvested and visualized in Backstage. +With the ability to create a plethora of components in Backstage, how does one keep track of all the software in the ecosystem? Therein lies the highlight feature of Phase 2: the [Service Catalog](https://github.com/backstage/backstage/milestone/4). The service catalog — or software catalog — is a centralized system that keeps track of ownership and metadata about all software in your ecosystem. The catalog is built around the concept of [metadata yaml files](/docs/architecture-decisions/adrs-adr002) stored together with the code, which are then harvested and visualized in Backstage. ![img](assets/20-05-20/Service_Catalog_MVP.png) @@ -39,13 +39,13 @@ On top of that, we have found that the service catalog is a great way to organis ![img](assets/20-05-20/tabs.png) -More concretely, having this structure in place will allow plugins such as [CircleCI](https://github.com/spotify/backstage/tree/master/plugins/circleci) to show only the builds for the specific service you are viewing, or a [Spinnaker](https://github.com/spotify/backstage/issues/631) plugin to show running deployments, or an Open API plugin to [show documentation](https://github.com/spotify/backstage/issues/627) for endpoints exposed by the service, or the [Lighthouse](https://github.com/spotify/backstage/tree/master/plugins/lighthouse) plugin to show audit reports for your website. You get the point. +More concretely, having this structure in place will allow plugins such as [CircleCI](https://github.com/backstage/backstage/tree/master/plugins/circleci) to show only the builds for the specific service you are viewing, or a [Spinnaker](https://github.com/backstage/backstage/issues/631) plugin to show running deployments, or an Open API plugin to [show documentation](https://github.com/backstage/backstage/issues/627) for endpoints exposed by the service, or the [Lighthouse](https://github.com/backstage/backstage/tree/master/plugins/lighthouse) plugin to show audit reports for your website. You get the point. ## Timeline Our estimated timeline has us delivering these pieces in increments leading up to June 22. But with the support of the community we wouldn’t be surprised if things land earlier than that. 🙏 -If you are interested in joining us, check out our [Milestones](https://github.com/spotify/backstage/milestones) and connected Issues. +If you are interested in joining us, check out our [Milestones](https://github.com/backstage/backstage/milestones) and connected Issues. ## Long-term vision diff --git a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md index 0928b8769b..e50d7a6d47 100644 --- a/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md +++ b/microsite/blog/2020-06-22-backstage-service-catalog-alpha.md @@ -24,7 +24,7 @@ With these insights we decided to re-focus our efforts towards the most requeste ## What is the service catalog? -The Backstage Service Catalog — actually, a software catalog, since it includes more than just services — is a centralized system that keeps track of ownership and metadata for all the software in your ecosystem (services, websites, libraries, data pipelines, etc). The catalog is built around the concept of [metadata yaml files](/docs/architecture-decisions/adr002-default-catalog-file-format.md#format) stored together with the code, which are then harvested and visualized in Backstage. +The Backstage Service Catalog — actually, a software catalog, since it includes more than just services — is a centralized system that keeps track of ownership and metadata for all the software in your ecosystem (services, websites, libraries, data pipelines, etc). The catalog is built around the concept of [metadata yaml files](/docs/architecture-decisions/adrs-adr002) stored together with the code, which are then harvested and visualized in Backstage. This was our pitch for the virtues of a service catalog when we first [announced](https://backstage.io/blog/2020/05/22/phase-2-service-catalog) it as part of Phase 2: @@ -40,7 +40,7 @@ You’ll be able to see many of these virtues in action with this alpha release Alpha is our shorthand for "we don’t yet think Backstage is ready for production, but we’d love for you to test it and provide us with feedback". However, you should be able to try out the functionality of the service catalog: -1. Register software components ([examples](https://github.com/spotify/backstage/tree/master/packages/catalog-model/examples)) +1. Register software components ([examples](https://github.com/backstage/backstage/tree/master/packages/catalog-model/examples)) 2. See all components represented in the catalog 3. Search across all components 4. Get an overview of the metadata of the components @@ -49,6 +49,6 @@ Alpha is our shorthand for "we don’t yet think Backstage is ready for producti As with most alpha releases, you should expect things to change quite a lot until we reach the beta stage (we’re targeting the end of summer). There are obviously many things missing as well, but we wanted to start collecting feedback early and make it easier to see the end-to-end flow. -If you have feedback or questions, please open a [GitHub issue](https://github.com/spotify/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send me an email at [alund@spotify.com](mailto:alund@spotify.com) 🙏 +If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send us an email at [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com) 🙏 To get regular product updates and news about the Backstage community, sign up for the [Backstage newsletter](https://mailchi.mp/spotify/backstage-community). diff --git a/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md b/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md index ccdd8b34c6..9d41a21d05 100644 --- a/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md +++ b/microsite/blog/2020-07-01-how-to-enable-authentication-in-backstage-using-passport.md @@ -21,7 +21,7 @@ Passport has allowed us to leverage an existing open-source authentication frame ![auth-landing-page](assets/20-07-01/auth-sidebar.png) -First, check out the provided Google and GitHub implementations! [Spin up a local copy of Backstage](https://backstage.io/blog/2020/04/30/how-to-quickly-set-up-backstage) along with our example-backend. You can find more documentation on setting up the example backend [here](https://github.com/spotify/backstage/tree/master/packages/backend), but be sure to include the relevant client IDs and secrets when running `yarn start`: +First, check out the provided Google and GitHub implementations! [Spin up a local copy of Backstage](https://backstage.io/blog/2020/04/30/how-to-quickly-set-up-backstage) along with our example-backend. You can find more documentation on setting up the example backend [here](https://github.com/backstage/backstage/tree/master/packages/backend), but be sure to include the relevant client IDs and secrets when running `yarn start`: ``` AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x SENTRY_TOKEN=x LOG_LEVEL=debug yarn start @@ -39,8 +39,8 @@ Getting started is really straightforward, and can be broadly broken down into f 4. Add the provider to the backend. 5. Add a frontend Auth Utility API. -For full details, take a look at our [“Adding authentication providers” documentation](/docs/auth/add-auth-provider.md) and at the [excellent documentation](http://www.passportjs.org/docs/) provided by Passport. +For full details, take a look at our [“Adding authentication providers” documentation](/docs/auth/add-auth-provider) and at the [excellent documentation](http://www.passportjs.org/docs/) provided by Passport. ## Interested in contributing to the next steps for authentication? -We’ve already seen both GitLab and Okta contributions from the community — and we’re thinking about a few more providers we’d like to add to Backstage, too. You can find those, and other authentication-related issues, in our repository by filtering with the [auth label](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+label%3Aauth). +We’ve already seen both GitLab and Okta contributions from the community — and we’re thinking about a few more providers we’d like to add to Backstage, too. You can find those, and other authentication-related issues, in our repository by filtering with the [auth label](https://github.com/backstage/backstage/issues?q=is%3Aissue+is%3Aopen+label%3Aauth). diff --git a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md index 81d495e1c3..bd6ae8eeeb 100644 --- a/microsite/blog/2020-08-05-announcing-backstage-software-templates.md +++ b/microsite/blog/2020-08-05-announcing-backstage-software-templates.md @@ -73,6 +73,6 @@ Backstage ships with four example templates, but since these are likely not the We have learned that one of the keys to getting these standards adopted is to keep an open process. Templates are code. By making it clear to your engineers that you are open to pull requests, and that teams with different needs can add their own templates, you are on the path of striking a good balance between autonomy and standardization. -If you have feedback or questions, please open a [GitHub issue](https://github.com/spotify/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send me an email at [alund@spotify.com](mailto:alund@spotify.com) 🙏 +If you have feedback or questions, please open a [GitHub issue](https://github.com/backstage/backstage/issues), ping us on [Discord chat](https://discord.gg/EBHEGzX) or send us an email at [backstage-interest@spotify.com](mailto:backstage-interest@spotify.com) 🙏 To get regular product updates and news about the Backstage community, sign up for the [Backstage newsletter](https://mailchi.mp/spotify/backstage-community). diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md index 48b6eeb7cc..ceab17d7be 100644 --- a/microsite/blog/2020-09-08-announcing-tech-docs.md +++ b/microsite/blog/2020-09-08-announcing-tech-docs.md @@ -21,11 +21,11 @@ But this is just one way to do it. Today we’re most excited for what the open ## Okay, let’s start collaborating -If you go to [GitHub](https://github.com/spotify/backstage/tree/master/plugins) now, you’ll find everything you need to start collaborating with us to build out the docs-like-code Backstage plugin — we’ll call it TechDocs in the open as well. +If you go to [GitHub](https://github.com/backstage/backstage/tree/master/plugins) now, you’ll find everything you need to start collaborating with us to build out the docs-like-code Backstage plugin — we’ll call it TechDocs in the open as well. -You’ll find the code in [techdocs](https://github.com/spotify/backstage/tree/master/plugins/techdocs) (frontend) and [techdocs-backend](https://github.com/spotify/backstage/tree/master/plugins/techdocs-backend). (There are also two separate packages [techdocs-cli](https://github.com/spotify/backstage/tree/master/packages/techdocs-cli) and [techdocs-container](https://github.com/spotify/backstage/tree/master/packages/techdocs-container).) +You’ll find the code in [techdocs](https://github.com/backstage/backstage/tree/master/plugins/techdocs) (frontend) and [techdocs-backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend). (There are also two separate packages [techdocs-cli](https://github.com/backstage/techdocs-cli) and [techdocs-container](https://github.com/backstage/techdocs-container).) -You’ll find issues to work on in the [issues queue](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+label%3A%22docs-like-code%22+label%3A%22help+wanted%22), typically starting with TechDocs: and labeled with docs-like-code, some labeled good first issue. Feel free to add your own issues, of course. +You’ll find issues to work on in the [issues queue](https://github.com/backstage/backstage/issues?q=is%3Aissue+is%3Aopen+label%3A%22docs-like-code%22+label%3A%22help+wanted%22), typically starting with TechDocs: and labeled with docs-like-code, some labeled good first issue. Feel free to add your own issues, of course. ![available-templates](assets/announcing-techdocs/github-issues.png) diff --git a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md index bcfd7c6245..8fc459cb62 100644 --- a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md +++ b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md @@ -14,8 +14,8 @@ Backstage garnered quite a bit of interest from developers and organizations whe Backstage’s ability to simplify tooling and standardize engineering practices has attracted interest from other major tech companies, as well as airlines, auto manufacturers, investment firms, and global retailers. We know that Backstage solves a problem — infrastructure complexity — that’s common to a lot of large and growing companies today. But different companies work differently, use particular toolsets, and have unique use cases. By making Backstage open source, we can build it with people working inside a variety of engineering organizations all over the world. It makes for a better product that serves a wider group of users (beyond that of Spotify’s) and their needs. -The Backstage community is healthy and growing quickly. Over [130 people](https://github.com/spotify/backstage/graphs/contributors) have contributed to the project, and roughly 40% of pull requests are now coming from external, non-Spotify, contributors. With companies now deciding to [adopt Backstage](https://github.com/spotify/backstage/blob/master/ADOPTERS.md) we are also seeing a shift in the kinds of contributions we are getting from the community. It is truly amazing to see contributions to core parts of the platform as well as significant functionality additions through working [plugins](https://backstage.io/plugins). +The Backstage community is healthy and growing quickly. Over [130 people](https://github.com/backstage/backstage/graphs/contributors) have contributed to the project, and roughly 40% of pull requests are now coming from external, non-Spotify, contributors. With companies now deciding to [adopt Backstage](https://github.com/backstage/backstage/blob/master/ADOPTERS.md) we are also seeing a shift in the kinds of contributions we are getting from the community. It is truly amazing to see contributions to core parts of the platform as well as significant functionality additions through working [plugins](https://backstage.io/plugins). We’re excited to embark on this journey with the CNCF community. There’s so much great tech being built here, and it’s about time we share it to build even greater products, together. Entering into the CNCF Sandbox is just the first step. We are committed to working with the community to bring Backstage through the Incubation step, and finally all the way to becoming a Graduated, top-level project. -Thanks to everyone for your support so far. We hope you [join us](https://mailchi.mp/spotify/backstage-community) in this next chapter of Backstage's journey. If you have questions or feedback, feel free to [email](mailto:alund@spotify.com) me directly. +Thanks to everyone for your support so far. We hope you [join us](https://mailchi.mp/spotify/backstage-community) in this next chapter of Backstage's journey. If you have questions or feedback, feel free to [email](mailto:backstage-interest@spotify.com) me directly. diff --git a/microsite/blog/2020-09-30-backstage-design-system.md b/microsite/blog/2020-09-30-backstage-design-system.md index 4faea98782..a1d087f755 100644 --- a/microsite/blog/2020-09-30-backstage-design-system.md +++ b/microsite/blog/2020-09-30-backstage-design-system.md @@ -68,9 +68,9 @@ To keep up with our latest design guidelines, go to [Designing for Backstage](ht ![img](assets/backstage-github-ds.png) -### [GitHub](https://github.com/spotify/backstage) +### [GitHub](https://github.com/backstage/backstage) -Join in on the action [at spotify/backstage on GitHub](https://github.com/spotify/backstage) by submitting issues and opening pull requests for all things related to components and patterns in Backstage. +Join in on the action [at backstage/backstage on GitHub](https://github.com/backstage/backstage) by submitting issues and opening pull requests for all things related to components and patterns in Backstage. ![img](assets/backstage-discord-DS.png) diff --git a/microsite/blog/2020-09-30-plugin-marketplace.md b/microsite/blog/2020-09-30-plugin-marketplace.md index b7a799097f..10928112b8 100644 --- a/microsite/blog/2020-09-30-plugin-marketplace.md +++ b/microsite/blog/2020-09-30-plugin-marketplace.md @@ -34,10 +34,10 @@ This grand vision is actually not that far off. Already today there is a growing Not all plugins you need will be open source. Every company has their own homegrown tooling. Building internal plugins lets you tailor your version of Backstage to be a perfect fit for your infrastructure and software development needs. If you end up [building plugins](https://backstage.io/docs/plugins/create-a-plugin) that could be useful for other companies, please consider releasing them as open source and [add them to the Marketplace](https://backstage.io/docs/plugins/add-to-marketplace). -If you start developing a plugin that you aim to release as open source, we suggest that you create a [new plugin Issue](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development and opens up opportunities for collaboration. You can also use this process if you have an idea for a good plugin, but you need help building it. +If you start developing a plugin that you aim to release as open source, we suggest that you create a [new plugin Issue](https://github.com/backstage/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME). This helps the community know what plugins are in development and opens up opportunities for collaboration. You can also use this process if you have an idea for a good plugin, but you need help building it. We are really excited to see all the amazing plugins that have already been built, and look forward to seeing even more ideas and collaboration as the Backstage community continues to grow. -What plugins would you like to see in the Plugin Marketplace? [Tell us](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME)! +What plugins would you like to see in the Plugin Marketplace? [Tell us](https://github.com/backstage/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME)! -_Special shout-out to community member [Iain Billett](https://github.com/iain-b) from Roadie for helping build and contribute the [Plugin Marketplace page](https://backstage.io/plugins) (as his first PR no less!)._ +_Special shout-out to community member [Iain Billett](https://github.com/iain-b) from [Roadie](https://roadie.io) for helping build and contribute the [Plugin Marketplace page](https://backstage.io/plugins) (as his first PR no less!)._ diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md new file mode 100644 index 0000000000..15647f8418 --- /dev/null +++ b/microsite/blog/2020-10-22-cost-insights-plugin.md @@ -0,0 +1,79 @@ +--- +title: New Cost Insights plugin: The engineer’s solution to taming cloud costs +author: Janisa Anandamohan +authorURL: https://twitter.com/janisa_a +--- + +How did Spotify save millions on cloud costs within a matter of months?? We made cost optimization just another part of the daily development process. Our newly open sourced [Cost Insights plugin](https://github.com/backstage/backstage/tree/master/plugins/cost-insights) makes a team’s cloud costs visible — and actionable — right inside Backstage. So engineers can see the impact of their cloud usage (down to a product and resource level) and make optimizations wherever and whenever it makes sense. By managing cloud costs from the ground up, you can make smarter decisions that let you continue to build and scale quickly, without wasting resources. + + + +Are we turning engineers into accountants? Nope, we’re just letting engineers do what they do best, in the place that feels natural to them: inside Backstage. + + + +## Why put a cost management tool in the hands of engineers? + +Engineers are closest to the metal in terms of knowing why a specific feature, product, or service is using cloud resources. So they’re in the best position to understand how costs impact ongoing development (and vice versa). + +If you manage costs top-down from a 10,000-foot view of your cloud infrastructure, you’re likely making decisions far removed from products, especially in larger organizations. Set a broad cost-cutting goal, and you could be creating unintended consequences — curtailing spending at the expense of growth or experimentation. + +## Ground-level intelligence, data-driven solutions + +Our hypothesis at Spotify was, if you bring spending data into an engineer’s everyday development workflow, they’ll naturally look for cost optimizations just like they look for any other optimization. And the cost optimizations will be more efficient and effective, because the decisions are informed at the ground level. + +The problem is that most cloud platforms don’t provide cost data at a granular enough level to make those decisions. And the bigger your organization (say, two-thousand-microservices and four-thousand-data-pipelines big, like Spotify), then the less you can attribute these large, fuzzy numbers to the right team, let alone a shipping product or internal service. + +That’s where Cost Insights comes in. Instead of making cost management and product development separate departments on the org chart, Backstage brings them together — with a level of detail and specificity engineers relate and respond to. + +## How to turn dollars into sense + +It’s not enough to make costs visible. To be useful, the numbers need to be relevant, relatable, and actionable. In other words, not just cost information, but insights. There are several ways the plugin puts data from your cloud provider in a more helpful context. + +### Use business metrics to evaluate costs + +Cost Insights will show you trends at a glance and also let you compare costs quarter over quarter. More importantly, you can also evaluate costs against business metrics that you care most about. In the example below, should the upward slope shown in the first screen be cause for worry? Perhaps not — if you switch views, you’ll see that cost per daily average user (DAU) is actually going down. Exactly what you hope to see as you scale. + +![Comparing costs to DAU](assets/20-10-22/cost-insights-1-dau.gif) +_(Note: Screens are examples; they do not show real data.)_ + +### Illustrate costs with relatable, real-world comparisons + +In addition to dollar amounts, Cost Insights allows teams to visualize and convert cost overages into more relatable terms. In the example below, we equate the growth in costs for virtual machine instances (100% increase) to developer time spent (about 1 engineer). We use this particular comparison in the plugin because we found it resonated with our own engineers — providing a useful perspective for spending increases. You can configure what the “cost of an engineer” means to your organization. Or engineers can build in their own comparisons — cups of coffee, carbon offset credits, electric luxury vehicles — whatever makes costs more tangible for them. + +![Cost growth as engineering time](assets/20-10-22/cost-insights-2-engineer.png) +_(Note: Screens are examples; they do not show real data.)_ + +### Tie spending to specific products and resources + +The more detailed the cost data, the more relevant, actionable, and helpful it is. Cost Insights allows you to attribute costs to products and resources in a way that makes sense to your engineers. For example, here we see a breakdown of data processing costs by individual pipelines. This allows your team to target optimizations more precisely. + +![Data Processing costs by pipeline](assets/20-10-22/cost-insights-3-data.png) +_(Note: Screens are examples; they do not show real data.)_ + +## Driving down costs without slowing down development + +When it comes to cutting costs, we actually want to guard against over-optimization. Growth and costs can go hand in hand. The trick is knowing when one is out of balance and needs addressing. Our product highlights when there’s been a large increase in spending, so that engineers are thinking about cost only when they must and aren’t distracted from their set goals and priorities. + +Engineers can then determine for themselves if the time invested in an optimization was valuable compared to the costs saved. Cost Insights puts the decision in our engineers’ hands for them to choose when to focus on growth efforts and when to focus on cost. Control, as ever, remains with our developers, where we think it belongs. + +## Getting started + +You can begin working with the Cost Insights plugin today on [GitHub](https://github.com/backstage/backstage/tree/master/plugins/cost-insights). We include an example client with static data in the expected format. The `CostInsightsApi` should talk with a cloud billing backend that aggregates billing data from your cloud provider. + +The current release of Cost Insights includes: + +- Daily cost graph by team or billing account +- Cost comparisons against configurable business metrics (including an option for Daily Active Users) +- Insights panels — configurable for the cloud products your company uses +- Cost alerts and recommendations +- Selectable time periods for month-over-month or quarter-over-quarter comparisons +- Conversion of cost growth into “cost of average engineer” to help optimization trade-off decisions + +Our hope is to help other companies translate their cloud cost in a relatable way for their engineers to better understand their impact and accurately identify their opportunities for optimizations. + +And if you’re interested in contributing to our outstanding issues, you can find them in the issues queue, filtered under the [‘cost-insights’ label](https://github.com/backstage/backstage/labels/cost-insights). + +## Ready for DevSecCostOpsPlus (and whatever’s next) + +There’s DevOps, there’s DevSecOps, and then there’s Backstage: one frontend for all your infrastructure. From building, testing, and deploying to monitoring and security — Backstage helps you manage your entire tech organization and provides a seamless developer experience for engineers, from end to end to end. And now that also extends to cost management for your cloud infrastructure and tooling. Happy building and [happy optimizing](https://github.com/backstage/backstage/tree/master/plugins/cost-insights). diff --git a/microsite/blog/assets/20-10-22/cost-insights-1-dau.gif b/microsite/blog/assets/20-10-22/cost-insights-1-dau.gif new file mode 100644 index 0000000000..df9396439d Binary files /dev/null and b/microsite/blog/assets/20-10-22/cost-insights-1-dau.gif differ diff --git a/microsite/blog/assets/20-10-22/cost-insights-2-engineer.png b/microsite/blog/assets/20-10-22/cost-insights-2-engineer.png new file mode 100644 index 0000000000..1e9293f5c1 Binary files /dev/null and b/microsite/blog/assets/20-10-22/cost-insights-2-engineer.png differ diff --git a/microsite/blog/assets/20-10-22/cost-insights-3-data.png b/microsite/blog/assets/20-10-22/cost-insights-3-data.png new file mode 100644 index 0000000000..941f0487da Binary files /dev/null and b/microsite/blog/assets/20-10-22/cost-insights-3-data.png differ diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index 8e3c4c086d..6ce3c3dd84 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -1,26 +1,22 @@ -/** - * Copyright (c) 2017-present, Facebook, Inc. +/* + * Copyright 2020 Backstage Project Authors. * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. + * 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. */ const React = require('react'); class Footer extends React.Component { - docUrl(doc, language) { - const baseUrl = this.props.config.baseUrl; - const docsUrl = this.props.config.docsUrl; - const docsPart = `${docsUrl ? `${docsUrl}/` : ''}`; - const langPart = `${language ? `${language}/` : ''}`; - return `${baseUrl}${docsPart}${langPart}${doc}`; - } - - pageUrl(doc, language) { - const baseUrl = this.props.config.baseUrl; - return baseUrl + (language ? `${language}/` : '') + doc; - } - render() { return ( ); diff --git a/microsite/core/GridBlockWithButton.js b/microsite/core/GridBlockWithButton.js deleted file mode 100644 index 0f77a6a148..0000000000 --- a/microsite/core/GridBlockWithButton.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Copyright (c) 2017-present, Facebook, Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -const React = require('react'); -const classNames = require('classnames'); - -const CompLibrary = require(`${process.cwd()}/node_modules/docusaurus/lib/core/CompLibrary.js`); -const MarkdownBlock = CompLibrary.MarkdownBlock; /* Used to read markdown */ - -class GridBlockWithButton extends React.Component { - renderBlock(origBlock) { - const blockDefaults = { - imageAlign: 'left', - }; - - const block = { - ...blockDefaults, - ...origBlock, - }; - - const blockClasses = classNames('blockElement', this.props.className, { - alignCenter: this.props.align === 'center', - alignRight: this.props.align === 'right', - fourByGridBlock: this.props.layout === 'fourColumn', - imageAlignSide: - block.image && - (block.imageAlign === 'left' || block.imageAlign === 'right'), - imageAlignTop: block.image && block.imageAlign === 'top', - imageAlignRight: block.image && block.imageAlign === 'right', - imageAlignBottom: block.image && block.imageAlign === 'bottom', - imageAlignLeft: block.image && block.imageAlign === 'left', - threeByGridBlock: this.props.layout === 'threeColumn', - twoByGridBlock: this.props.layout === 'twoColumn', - }); - - const topLeftImage = - (block.imageAlign === 'top' || block.imageAlign === 'left') && - this.renderBlockImage(block.image, block.imageLink, block.imageAlt); - - const bottomRightImage = - (block.imageAlign === 'bottom' || block.imageAlign === 'right') && - this.renderBlockImage(block.image, block.imageLink, block.imageAlt); - - return ( -
- {topLeftImage} -
- {this.renderBlockTitle(block.title)} - {block.content} - -
- {bottomRightImage} -
- ); - } - - renderBlockImage(image) { - if (!image) { - return null; - } - - return
{image}
; - } - - renderBlockTitle(title) { - if (!title) { - return null; - } - - return ( -

- {title} -

- ); - } - - render() { - return ( -
- {this.props.contents.map(this.renderBlock, this)} -
- ); - } -} - -GridBlockWithButton.defaultProps = { - align: 'left', - contents: [], - layout: 'twoColumn', -}; - -module.exports = GridBlockWithButton; diff --git a/microsite/data/plugins/api-docs.yaml b/microsite/data/plugins/api-docs.yaml index babcbf74f1..03ec818445 100644 --- a/microsite/data/plugins/api-docs.yaml +++ b/microsite/data/plugins/api-docs.yaml @@ -4,6 +4,6 @@ author: SDA SE authorUrl: https://sda.se/ category: Discovery description: Components to discover and display API entities as an extension to the catalog plugin. -documentation: https://github.com/spotify/backstage/blob/master/plugins/api-docs/README.md -iconUrl: https://thecoders.io/wp-content/uploads/2019/11/tech-swagger.svg +documentation: https://github.com/backstage/backstage/blob/master/plugins/api-docs/README.md +iconUrl: https://raw.githubusercontent.com/vscode-icons/vscode-icons/master/icons/file_type_swagger.svg npmPackageName: '@backstage/plugin-api-docs' diff --git a/microsite/data/plugins/aws-lambda.yaml b/microsite/data/plugins/aws-lambda.yaml new file mode 100644 index 0000000000..f325c0df8b --- /dev/null +++ b/microsite/data/plugins/aws-lambda.yaml @@ -0,0 +1,9 @@ +--- +title: AWS Lambda +author: roadie.io +authorUrl: https://roadie.io +category: Monitoring +description: View AWS Lambda functions for your components in Backstage. +documentation: https://roadie.io/backstage/plugins/aws-lambda +iconUrl: https://roadie.io/images/logos/lambda.png +npmPackageName: '@roadiehq/backstage-plugin-aws-lambda' diff --git a/microsite/data/plugins/buildkite.yaml b/microsite/data/plugins/buildkite.yaml new file mode 100644 index 0000000000..eeb9440aac --- /dev/null +++ b/microsite/data/plugins/buildkite.yaml @@ -0,0 +1,12 @@ +--- +title: Buildkite +author: roadie.io +authorUrl: https://roadie.io +category: CI +description: View Buildkite CI builds for your service in Backstage. +documentation: https://roadie.io/backstage/plugins/buildkite +iconUrl: https://roadie.io/images/logos/buildkite.png +npmPackageName: '@roadiehq/backstage-plugin-buildkite' +tags: + - ci + - cd diff --git a/microsite/data/plugins/circleci.yaml b/microsite/data/plugins/circleci.yaml index 5f7b4b08e2..467da9fb1e 100644 --- a/microsite/data/plugins/circleci.yaml +++ b/microsite/data/plugins/circleci.yaml @@ -4,7 +4,7 @@ author: Spotify authorUrl: https://github.com/spotify category: CI description: Automate your development process with CI hosted in the cloud or on a private server. -documentation: https://github.com/spotify/backstage/tree/master/plugins/circleci +documentation: https://github.com/backstage/backstage/tree/master/plugins/circleci iconUrl: https://www.saaves.com/storage/brochure/logo-circleci-icon1583764538.png npmPackageName: '@backstage/plugin-circleci' tags: diff --git a/microsite/data/plugins/cloud-build.yaml b/microsite/data/plugins/cloud-build.yaml index cbd3a3cb6c..2b40d02de2 100644 --- a/microsite/data/plugins/cloud-build.yaml +++ b/microsite/data/plugins/cloud-build.yaml @@ -4,7 +4,7 @@ author: Trivago authorUrl: https://www.trivago.com category: CI description: Build, test, and deploy on Google's serverless CI/CD platform. -documentation: https://github.com/spotify/backstage/tree/master/plugins/cloudbuild +documentation: https://github.com/backstage/backstage/tree/master/plugins/cloudbuild iconUrl: https://avatars2.githubusercontent.com/u/38220399?s=400&v=4 npmPackageName: '@backstage/plugin-cloudbuild' tags: diff --git a/microsite/data/plugins/cost-insights.yaml b/microsite/data/plugins/cost-insights.yaml index 7950879833..daa4f55a02 100644 --- a/microsite/data/plugins/cost-insights.yaml +++ b/microsite/data/plugins/cost-insights.yaml @@ -4,7 +4,7 @@ author: Spotify authorUrl: https://github.com/spotify category: Discovery description: Visualize, understand and optimize your team's cloud costs. -documentation: https://github.com/spotify/backstage/tree/master/plugins/cost-insights +documentation: https://github.com/backstage/backstage/tree/master/plugins/cost-insights iconUrl: https://www.materialui.co/materialIcons/editor/monetization_on_white_192x192.png npmPackageName: '@backstage/plugin-cost-insights' tags: diff --git a/microsite/data/plugins/firebase-functions.yaml b/microsite/data/plugins/firebase-functions.yaml index 097ef00e50..8c1ea0be0a 100644 --- a/microsite/data/plugins/firebase-functions.yaml +++ b/microsite/data/plugins/firebase-functions.yaml @@ -5,5 +5,5 @@ authorUrl: https://roadie.io/ category: Monitoring description: View Firebase Functions details for your service in Backstage. documentation: https://roadie.io/backstage/plugins/firebase-functions -iconUrl: https://roadie.io/static/49fb23200ad0eaa6703b4ddf75c78cf1/45f2b/logo-vertical.png +iconUrl: https://roadie.io/images/logos/firebase.png npmPackageName: '@roadiehq/backstage-plugin-firebase-functions' diff --git a/microsite/data/plugins/gcp-projects.yaml b/microsite/data/plugins/gcp-projects.yaml index 7eff42af98..1c015821b2 100644 --- a/microsite/data/plugins/gcp-projects.yaml +++ b/microsite/data/plugins/gcp-projects.yaml @@ -4,7 +4,7 @@ author: Trivago authorUrl: https://www.trivago.com category: Cloud description: Create, list and manage your Google Cloud Projects. -documentation: https://github.com/spotify/backstage/tree/master/plugins/gcp-projects +documentation: https://github.com/backstage/backstage/tree/master/plugins/gcp-projects iconUrl: https://avatars1.githubusercontent.com/u/2810941?s=280&v=4 npmPackageName: '@backstage/plugin-gcp-projects' tags: diff --git a/microsite/data/plugins/github-actions.yaml b/microsite/data/plugins/github-actions.yaml index 062d0fff61..aeccdc9b86 100644 --- a/microsite/data/plugins/github-actions.yaml +++ b/microsite/data/plugins/github-actions.yaml @@ -4,7 +4,7 @@ author: Spotify authorUrl: https://github.com/spotify category: CI description: GitHub Actions makes it easy to automate all your software workflows, now with world-class CI/CD. Build, test, and deploy your code right from GitHub. -documentation: https://github.com/spotify/backstage/tree/master/plugins/github-actions +documentation: https://github.com/backstage/backstage/tree/master/plugins/github-actions iconUrl: https://avatars2.githubusercontent.com/u/44036562?s=400&v=4 npmPackageName: '@backstage/plugin-github-actions' tags: diff --git a/microsite/data/plugins/github-insights.yaml b/microsite/data/plugins/github-insights.yaml new file mode 100644 index 0000000000..1dba3dcfd1 --- /dev/null +++ b/microsite/data/plugins/github-insights.yaml @@ -0,0 +1,9 @@ +--- +title: GitHub Insights +author: roadie.io +authorUrl: https://roadie.io +category: Monitoring +description: View GitHub Insights for your components in Backstage. +documentation: https://roadie.io/backstage/plugins/github-insights +iconUrl: https://roadie.io/images/logos/insights.png +npmPackageName: '@roadiehq/backstage-plugin-github-insights' diff --git a/microsite/data/plugins/github-pull-requests.yaml b/microsite/data/plugins/github-pull-requests.yaml index 6479452c8e..8e44a4bf56 100644 --- a/microsite/data/plugins/github-pull-requests.yaml +++ b/microsite/data/plugins/github-pull-requests.yaml @@ -5,5 +5,5 @@ authorUrl: https://roadie.io/ category: CI description: View GitHub pull requests for your service in Backstage. documentation: https://roadie.io/backstage/plugins/github-pull-requests -iconUrl: https://roadie.io/static/7f13bb8d861d8dedc5112fb939d215f9/351f2/GitHub-Mark-Light-120px-plus.png +iconUrl: https://roadie.io/images/logos/github.png npmPackageName: '@roadiehq/backstage-plugin-github-pull-requests' diff --git a/microsite/data/plugins/gitops-cluster.yaml b/microsite/data/plugins/gitops-cluster.yaml index 6f8ab6b097..ad6e7979aa 100644 --- a/microsite/data/plugins/gitops-cluster.yaml +++ b/microsite/data/plugins/gitops-cluster.yaml @@ -4,7 +4,7 @@ author: Weaveworks authorUrl: https://www.weave.works/ category: Kubernetes description: Create GitOps-managed Kubernetes clusters. Currently, it supports provisioning EKS clusters on GitHub via GitHub Actions. -documentation: https://github.com/spotify/backstage/tree/master/plugins/gitops-profiles +documentation: https://github.com/backstage/backstage/tree/master/plugins/gitops-profiles iconUrl: https://res-5.cloudinary.com/crunchbase-production/image/upload/c_lpad,h_256,w_256,f_auto,q_auto:eco/v1462316670/i9d3delzvx1erzjhmcws.png npmPackageName: '@backstage/plugin-gitops-profiles' tags: diff --git a/microsite/data/plugins/graphiql.yaml b/microsite/data/plugins/graphiql.yaml index e997360b04..eda91206eb 100644 --- a/microsite/data/plugins/graphiql.yaml +++ b/microsite/data/plugins/graphiql.yaml @@ -4,7 +4,7 @@ author: Spotify authorUrl: https://github.com/spotify category: Debugging description: Integrates GraphiQL as a tool to browse GraphQL API endpoints inside Backstage. -documentation: https://github.com/spotify/backstage/tree/master/plugins/graphiql +documentation: https://github.com/backstage/backstage/tree/master/plugins/graphiql iconUrl: https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/GraphQL_Logo.svg/1024px-GraphQL_Logo.svg.png npmPackageName: '@backstage/plugin-graphiql' tags: diff --git a/microsite/data/plugins/jenkins.yaml b/microsite/data/plugins/jenkins.yaml index 78b3616595..638758d54d 100644 --- a/microsite/data/plugins/jenkins.yaml +++ b/microsite/data/plugins/jenkins.yaml @@ -4,7 +4,7 @@ author: '@timja' authorUrl: https://github.com/timja category: CI description: Jenkins offers a simple way to set up a continuous integration and continuous delivery environment. -documentation: https://github.com/spotify/backstage/tree/master/plugins/jenkins +documentation: https://github.com/backstage/backstage/tree/master/plugins/jenkins iconUrl: https://img.icons8.com/color/1600/jenkins.png npmPackageName: '@backstage/plugin-jenkins' tags: diff --git a/microsite/data/plugins/lighthouse.yaml b/microsite/data/plugins/lighthouse.yaml index 0819455ed2..88d444e0ef 100644 --- a/microsite/data/plugins/lighthouse.yaml +++ b/microsite/data/plugins/lighthouse.yaml @@ -4,7 +4,7 @@ author: Spotify authorUrl: https://github.com/spotify category: Accessibility description: Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website. -documentation: https://github.com/spotify/backstage/tree/master/plugins/lighthouse +documentation: https://github.com/backstage/backstage/tree/master/plugins/lighthouse iconUrl: https://seeklogo.com/images/G/google-lighthouse-logo-1C7FA08580-seeklogo.com.png npmPackageName: '@backstage/plugin-lighthouse' tags: diff --git a/microsite/data/plugins/new-relic.yaml b/microsite/data/plugins/new-relic.yaml index e3ddf18652..d728a9d1c7 100644 --- a/microsite/data/plugins/new-relic.yaml +++ b/microsite/data/plugins/new-relic.yaml @@ -4,7 +4,7 @@ author: '@timwheelercom' authorUrl: https://github.com/timwheelercom category: Monitoring description: Observability platform built to help engineers create and monitor their software. -documentation: https://github.com/spotify/backstage/tree/master/plugins/newrelic +documentation: https://github.com/backstage/backstage/tree/master/plugins/newrelic iconUrl: https://www.mulesoft.com/sites/default/files/2018-10/New_relic.png npmPackageName: '@backstage/plugin-newrelic' tags: diff --git a/microsite/data/plugins/rollbar.yaml b/microsite/data/plugins/rollbar.yaml index 8ef3d8d66a..05f0d4ef78 100644 --- a/microsite/data/plugins/rollbar.yaml +++ b/microsite/data/plugins/rollbar.yaml @@ -4,6 +4,6 @@ author: '@andrewthauer' authorUrl: https://github.com/andrewthauer category: Monitoring description: View Rollbar errors for your services in Backstage. -documentation: https://github.com/spotify/backstage/tree/master/plugins/rollbar +documentation: https://github.com/backstage/backstage/tree/master/plugins/rollbar iconUrl: https://rollbar.com/assets/media/rollbar-mark-color.png npmPackageName: '@backstage/plugin-rollbar' diff --git a/microsite/data/plugins/security-insights.yaml b/microsite/data/plugins/security-insights.yaml new file mode 100644 index 0000000000..4f34f79027 --- /dev/null +++ b/microsite/data/plugins/security-insights.yaml @@ -0,0 +1,9 @@ +--- +title: Security Insights +author: roadie.io +authorUrl: https://roadie.io/ +category: Security +description: View Security Insights for your components in Backstage. +documentation: https://roadie.io/backstage/plugins/security-insights +iconUrl: https://roadie.io/images/logos/github.png +npmPackageName: '@roadiehq/backstage-plugin-security-insights' diff --git a/microsite/data/plugins/sentry.yaml b/microsite/data/plugins/sentry.yaml index 8da488f727..3445d6abb7 100644 --- a/microsite/data/plugins/sentry.yaml +++ b/microsite/data/plugins/sentry.yaml @@ -4,6 +4,6 @@ author: Spotify authorUrl: https://github.com/spotify category: Monitoring description: View Sentry issues in Backstage. -documentation: https://github.com/spotify/backstage/tree/master/plugins/sentry +documentation: https://github.com/backstage/backstage/tree/master/plugins/sentry iconUrl: https://sentry-brand.storage.googleapis.com/sentry-glyph-white.png npmPackageName: '@backstage/plugin-sentry' diff --git a/microsite/data/plugins/sonarqube.yaml b/microsite/data/plugins/sonarqube.yaml new file mode 100644 index 0000000000..e9e22d18a4 --- /dev/null +++ b/microsite/data/plugins/sonarqube.yaml @@ -0,0 +1,9 @@ +--- +title: SonarQube +author: SDA SE +authorUrl: https://sda.se/ +category: Quality +description: Components to display code quality metrics from SonarCloud and SonarQube. +documentation: https://github.com/backstage/backstage/blob/master/plugins/sonarqube/README.md +iconUrl: img/sonarqube-icon.svg +npmPackageName: '@backstage/plugin-sonarqube' diff --git a/microsite/data/plugins/tech-radar.yaml b/microsite/data/plugins/tech-radar.yaml index b1d4f5cc17..f2d5b12663 100644 --- a/microsite/data/plugins/tech-radar.yaml +++ b/microsite/data/plugins/tech-radar.yaml @@ -4,6 +4,6 @@ author: Spotify authorUrl: https://github.com/spotify category: Discovery description: Visualize the your company's official guidelines of different areas of software development. -documentation: https://github.com/spotify/backstage/tree/master/plugins/tech-radar +documentation: https://github.com/backstage/backstage/tree/master/plugins/tech-radar iconUrl: https://www.materialui.co/materialIcons/action/track_changes_white_192x192.png npmPackageName: '@backstage/plugin-tech-radar' diff --git a/microsite/data/plugins/travis-ci.yaml b/microsite/data/plugins/travis-ci.yaml index 520b884c20..fe1b18c9d4 100644 --- a/microsite/data/plugins/travis-ci.yaml +++ b/microsite/data/plugins/travis-ci.yaml @@ -5,5 +5,5 @@ authorUrl: https://roadie.io/ category: CI description: View Travis CI builds for your service in Backstage. documentation: https://roadie.io/backstage/plugins/travis-ci -iconUrl: https://roadie.io/static/af2941eaf0af675facb281d566f42e14/45f2b/travis-ci-mascot-200x200.png +iconUrl: https://roadie.io/images/logos/travis.png npmPackageName: '@roadiehq/backstage-plugin-travis-ci' diff --git a/microsite/package.json b/microsite/package.json index 335ada7212..a84364c214 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -14,8 +14,8 @@ "rename-version": "docusaurus-rename-version" }, "devDependencies": { - "@spotify/prettier-config": "^8.0.0", - "docusaurus": "^2.0.0-alpha.65", + "@spotify/prettier-config": "^9.0.0", + "docusaurus": "^2.0.0-alpha.66", "js-yaml": "^3.14.0", "prettier": "^2.0.5" }, diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js index c6657c6a31..63c15ab698 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -78,7 +78,41 @@ const Background = props => { - + + + + Control cloud costs + + How do you control cloud costs while maintaining the speed and + independence of your development teams? With the{' '} + Cost Insights plugin{' '} + for Backstage, managing cloud costs becomes just another part of + an engineer’s daily development process. They get a clear view of + their spending — and can decide for themselves how they want to + optimize it. Learn more about the{' '} + + Cost Insights plugin + + . + + + Watch now + + + + + + + + + @@ -96,7 +130,7 @@ const Background = props => { . - + Watch now diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index 6e649aa574..0422bfab63 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -31,7 +31,7 @@ class Index extends React.Component { high-quality code quickly — without compromising autonomy. GitHub @@ -282,7 +282,7 @@ class Index extends React.Component { Build your own software templates Contribute diff --git a/microsite/pages/en/plugins.js b/microsite/pages/en/plugins.js index a8255bcd0d..b1854135dc 100644 --- a/microsite/pages/en/plugins.js +++ b/microsite/pages/en/plugins.js @@ -16,9 +16,8 @@ const { const pluginsDirectory = require('path').join(process.cwd(), 'data/plugins'); const pluginMetadata = fs .readdirSync(pluginsDirectory) - .map(file => - yaml.safeLoad(fs.readFileSync(`./data/plugins/${file}`, 'utf8')), - ); + .map(file => yaml.safeLoad(fs.readFileSync(`./data/plugins/${file}`, 'utf8'))) + .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); const truncate = text => text.length > 170 ? text.substr(0, 170) + '...' : text; @@ -44,7 +43,7 @@ const Plugins = () => ( - + {pluginMetadata.map( ({ iconUrl, @@ -57,26 +56,25 @@ const Plugins = () => ( }) => (
- {title} -

{title}

-

- by {author} -

- {category} +
+ {title} +
+
+

{title}

+

+ by {author} +

+ {category} +

{truncate(description)}

- - - - Explore - - - +
), )} @@ -100,11 +98,11 @@ const Plugins = () => (

See what plugins are already{' '} - + in progress {' '} and 👍. Missing a plugin for your favorite tool? Please{' '} - + suggest {' '} a new one. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 7081e2bb1c..e60d9c520e 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -7,6 +7,7 @@ "overview/vision", "overview/background", "overview/adopting", + "overview/stability-index", "overview/logos" ], "Getting Started": [ @@ -28,6 +29,7 @@ "label": "Deployment", "ids": [ "getting-started/deployment-k8s", + "getting-started/deployment-helm", "getting-started/deployment-other" ] } @@ -44,6 +46,7 @@ "features/software-catalog/descriptor-format", "features/software-catalog/references", "features/software-catalog/well-known-annotations", + "features/software-catalog/well-known-relations", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", "features/software-catalog/software-catalog-api" @@ -163,7 +166,7 @@ "architecture-decisions/adrs-adr009" ], "Contribute": ["../CONTRIBUTING"], - "Support": ["overview/support"], + "Support": ["support/support", "support/project-structure"], "FAQ": ["FAQ"] } } diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index d034321d6c..6e8f8edddf 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -17,7 +17,7 @@ const siteConfig = { url: 'https://backstage.io', // Your website URL cname: 'backstage.io', baseUrl: '/', // Base URL for your project */ - editUrl: 'https://github.com/spotify/backstage/edit/master/docs/', + editUrl: 'https://github.com/backstage/backstage/edit/master/docs/', // Used for publishing and more projectName: 'backstage', @@ -30,7 +30,7 @@ const siteConfig = { // For no header links in the top nav bar -> headerLinks: [], headerLinks: [ { - href: 'https://github.com/spotify/backstage', + href: 'https://github.com/backstage/backstage', label: 'GitHub', }, { @@ -60,7 +60,7 @@ const siteConfig = { /* path to images for header/footer */ // headerIcon: "img/android-chrome-192x192.png", footerIcon: 'img/android-chrome-192x192.png', - favicon: 'img/favicon.svg', + favicon: 'img/favicon.ico', /* Colors for website */ colors: { @@ -78,7 +78,7 @@ const siteConfig = { }, // This copyright info is used in /core/Footer.js and blog RSS/Atom feeds. - copyright: `Copyright © ${new Date().getFullYear()} Spotify AB`, + copyright: `Copyright © ${new Date().getFullYear()} Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage`, highlight: { // Highlight.js theme to use for syntax highlighting in code blocks. @@ -111,7 +111,7 @@ const siteConfig = { // You may provide arbitrary config keys to be used as needed by your // template. For example, if you need your repo's URL... - repoUrl: 'https://github.com/spotify/backstage', + repoUrl: 'https://github.com/backstage/backstage', twitterUsername: 'SpotifyEng', stylesheets: [ diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 738478724f..22855a2b4d 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -15,9 +15,6 @@ table tr:nth-child(2n) { background-color: #2b2b2b; } -@media only screen and (min-device-width: 360px) and (max-device-width: 736px) { -} - @media only screen and (min-width: 1024px) and (max-width: 1500px) { /* Experience */ .content-experience { @@ -857,7 +854,6 @@ code { position: relative; padding-top: 75%; width: 100%; - /* border: 1px dotted #f00; */ } .Block__Graphic { @@ -1084,3 +1080,17 @@ code { margin-bottom: 40px; margin-top: 20px; } + +.nav-footer .copyright { + width: 600px; + color: #fff; + margin: 0 auto; + font-size: 10pt; +} + +@media only screen and (max-width: 485px) { + .nav-footer .copyright { + width: auto; + margin: 0 1.5em; + } +} diff --git a/microsite/static/css/plugins.css b/microsite/static/css/plugins.css index 9a8bbafa46..8a0d843dc9 100644 --- a/microsite/static/css/plugins.css +++ b/microsite/static/css/plugins.css @@ -6,7 +6,7 @@ flex-direction: column; } -.grid { +.PluginGrid { display: grid; grid-gap: 1rem; grid-template-columns: repeat(4, 1fr); @@ -15,17 +15,23 @@ } @media (max-width: 1200px) { - .grid { + .PluginGrid { grid-template-columns: repeat(3, 1fr); } } @media only screen and (max-width: 815px) { - .grid { + .PluginGrid { grid-template-columns: repeat(2, 1fr); } } +@media only screen and (max-width: 485px) { + .PluginGrid { + grid-template-columns: 1fr; + } +} + .PluginCard img { float: left; margin: 0px 16px 8px 0px; @@ -34,14 +40,32 @@ } .PluginCardHeader { + display: flex; + flex-direction: row; + align-items: center; max-height: fit-content; min-height: fit-content; } +.PluginCardImage { + width: 80px; + height: 80px; + margin-right: 16px; +} + +.PluginCardImage img { + width: 100%; + max-width: 100%; +} + .PluginCardTitle { color: white; vertical-align: top; - margin: 8px 0px 0px 16px; + margin: 8px 0 0; +} + +.PluginCardInfo { + flex: 1; } .PluginAddNewButton { @@ -50,11 +74,16 @@ right: 0px; } +@media only screen and (max-width: 485px) { + .PluginAddNewButton { + bottom: -4px; + } +} + .ButtonFilled { padding: 4px 8px; border-radius: 4px; color: #69ddc7; - margin-top: 36px; } .ButtonFilled:hover { @@ -62,7 +91,7 @@ background-color: transparent; } -.ChipOutlined { +.PluginCardChipOutlined { font-size: small; border-radius: 16px; padding: 2px 8px; @@ -70,11 +99,16 @@ color: #69ddc7; } -.PluginCardLink { +.PluginCardFooter { + display: flex; + justify-content: flex-end; + align-items: flex-end; + margin-top: auto; + min-height: 2em; +} + +.PluginCardFooter a { padding: 2px 8px; - position: absolute; - bottom: 0; - right: 0; } .PluginPageLayout { @@ -95,18 +129,13 @@ padding-top: 8px; } -.PluginCardFooter { - position: relative; - min-height: 2em; -} - -.Author, -.Author a { +.PluginCardAuthor, +.PluginCardAuthor a { margin-bottom: 0.25em; color: rgba(255, 255, 255, 0.6); } -.Author a:hover { +.PluginCardAuthor a:hover { color: white; } diff --git a/microsite/static/img/sonarqube-icon.svg b/microsite/static/img/sonarqube-icon.svg new file mode 100644 index 0000000000..6e685d988a --- /dev/null +++ b/microsite/static/img/sonarqube-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/microsite/static/schema/config-v1 b/microsite/static/schema/config-v1 new file mode 100644 index 0000000000..f85f2844cc --- /dev/null +++ b/microsite/static/schema/config-v1 @@ -0,0 +1,170 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://backstage.io/schema/config-v1", + "title": "Backstage Configuration Meta-Schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { "$ref": "#/definitions/nonNegativeInteger" }, + { "default": 0 } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + }, + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, + "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { "$ref": "#" }, + "items": { + "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/schemaArray" }], + "default": true + }, + "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, + "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { "$ref": "#" }, + "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, + "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { "$ref": "#" }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/stringArray" }] + } + }, + "propertyNames": { "$ref": "#" }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentEncoding": { "type": "string" }, + "if": { "$ref": "#" }, + "then": { "$ref": "#" }, + "else": { "$ref": "#" }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" }, + "visibility": { + "type": "string", + "enum": ["frontend", "backend", "secret"] + } + }, + "default": true +} diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 40e46c3545..b120cb2d47 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -959,10 +959,10 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== -"@spotify/prettier-config@^8.0.0": - version "8.0.0" - resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-8.0.0.tgz#8b6c2bd579ddc54887155a0721fe04e96c89f7f2" - integrity sha512-so8w32ZV42CHWxOEXcBtbNO/hLXFrQNXVmhfzhUI6dVB9cq2xjRaiqu8GjFj8LvKbWpPj+S+KwTIS4aDVWqrFQ== +"@spotify/prettier-config@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@spotify/prettier-config/-/prettier-config-9.0.0.tgz#7b562d56573c6fc0094446fbc92b22bc318945dc" + integrity sha512-In1q0tIiqTYKAGe3KOHDcFDdZRFISyQeSeipeTHGfki23ebHRZcjxvqj5SSdBkw65D4VpSREMi0s9i5iJiMcTw== "@types/cheerio@^0.22.8": version "0.22.21" @@ -2233,10 +2233,10 @@ dir-glob@2.0.0: arrify "^1.0.1" path-type "^3.0.0" -docusaurus@^2.0.0-alpha.65: - version "2.0.0-alpha.65" - resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.65.tgz#e2b84985529deb79797aff52aa39ffbe03cd8a20" - integrity sha512-OEnKbXLPy3EpbQA/Dj0kakB6fg/Qni1LORTIero+5Q5G0QT5OH4FAbBQOy3L1qQH0S5Bv18zESxX9Nvclg20ag== +docusaurus@^2.0.0-alpha.66: + version "2.0.0-alpha.66" + resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.66.tgz#4dece48b838f773c4c973d9fe2546dd0bf2637cf" + integrity sha512-/HmRt3hEqpvZRdR2JRYKpwNUgalO14exxuwQu2vXW5eYzcKbQ922+3SxG7mF+LE8f7KOmgY+A3eS3SEyq+S3XA== dependencies: "@babel/core" "^7.9.0" "@babel/plugin-proposal-class-properties" "^7.8.3" diff --git a/mkdocs.yml b/mkdocs.yml index 4fbec4650d..8a4a78ddf1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,7 +13,6 @@ nav: - Getting started: - Getting Started: 'getting-started/index.md' - Running Backstage locally: 'getting-started/running-backstage-locally.md' - - Installation: 'getting-started/installation.md' - Local development: 'getting-started/development-environment.md' - Demo deployment: https://backstage-demo.roadie.io - Production deployments: @@ -23,6 +22,7 @@ nav: - Customize the look-and-feel of your App: 'getting-started/app-custom-theme.md' - Deployment scenarios: - Kubernetes: 'getting-started/deployment-k8s.md' + - Kubernetes and Helm: 'getting-started/deployment-helm.md' - Other: 'getting-started/deployment-other.md' - Features: - Software Catalog: @@ -107,7 +107,9 @@ nav: - ADR008 - Default Catalog File Name: 'architecture-decisions/adr008-default-catalog-file-name.md' - ADR009 - Entity References: 'architecture-decisions/adr009-entity-references.md' - Contribute: '../CONTRIBUTING.md' - - Support: 'overview/support.md' + - Support: + - 'support/support.md' + - 'support/project-structure.md' - FAQ: FAQ.md plugins: diff --git a/package.json b/package.json index b9c84a4d90..4140886bc3 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "docker-build": "yarn tsc && yarn workspace example-backend build-image", "create-plugin": "backstage-cli create-plugin --scope backstage --no-private", "remove-plugin": "backstage-cli remove-plugin", - "release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push --force-publish; fi", + "release": "changeset version && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}'", "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", @@ -36,11 +36,15 @@ "plugins/*" ] }, + "resolutions": { + "**/@roadiehq/backstage-plugin-*/@backstage/core": "*" + }, "version": "1.0.0", "devDependencies": { - "@changesets/cli": "2.10.2", + "@changesets/cli": "^2.11.0", "@spotify/eslint-config-oss": "^1.0.1", "@spotify/prettier-config": "^8.0.0", + "command-exists": "^1.2.9", "concurrently": "^5.2.0", "fs-extra": "^9.0.0", "husky": "^4.2.3", @@ -65,7 +69,7 @@ "prettier --write" ], "*.md": [ - "vale" + "node ./scripts/check-docs-quality" ] }, "jest": { diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md new file mode 100644 index 0000000000..ab00924a26 --- /dev/null +++ b/packages/app/CHANGELOG.md @@ -0,0 +1,193 @@ +# example-app + +## 0.2.2 + +### Patch Changes + +- 3efd03c0e: Removed obsolete CircleCI proxy config from example-app +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [17a9f48f6] +- Updated dependencies [4040d4fcb] +- Updated dependencies [f360395d0] +- Updated dependencies [259d848ee] +- Updated dependencies [8b7737d0b] +- Updated dependencies [902340451] + - @backstage/cli@0.3.0 + - @backstage/core@0.3.1 + - @backstage/plugin-cost-insights@0.4.0 + - @backstage/plugin-lighthouse@0.2.2 + - @backstage/plugin-rollbar@0.2.2 + - @backstage/plugin-sentry@0.2.2 + - @backstage/plugin-techdocs@0.2.2 + - @backstage/plugin-user-settings@0.2.2 + - @backstage/plugin-catalog@0.2.2 + - @backstage/test-utils@0.1.3 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [a41730c6e] +- Updated dependencies [9a294574c] +- Updated dependencies [0703edee0] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [5a2705de2] +- Updated dependencies [0c0798f08] +- Updated dependencies [84b654d5d] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [3f05616bf] +- Updated dependencies [803527bd3] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] +- Updated dependencies [59166e5ec] +- Updated dependencies [a906f20e7] + - @backstage/core@0.3.0 + - @backstage/plugin-jenkins@0.3.0 + - @backstage/plugin-cost-insights@0.3.0 + - @backstage/plugin-user-settings@0.2.1 + - @backstage/plugin-api-docs@0.2.1 + - @backstage/plugin-tech-radar@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + - @backstage/plugin-scaffolder@0.3.0 + - @backstage/plugin-circleci@0.2.1 + - @backstage/plugin-cloudbuild@0.2.1 + - @backstage/plugin-explore@0.2.1 + - @backstage/plugin-gcp-projects@0.2.1 + - @backstage/plugin-github-actions@0.2.1 + - @backstage/plugin-gitops-profiles@0.2.1 + - @backstage/plugin-graphiql@0.2.1 + - @backstage/plugin-kubernetes@0.2.1 + - @backstage/plugin-lighthouse@0.2.1 + - @backstage/plugin-newrelic@0.2.1 + - @backstage/plugin-register-component@0.2.1 + - @backstage/plugin-rollbar@0.2.1 + - @backstage/plugin-sentry@0.2.1 + - @backstage/plugin-techdocs@0.2.1 + - @backstage/plugin-welcome@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 6d97d2d6f: The InfoCard variant `'height100'` is deprecated. Use variant `'gridItem'` instead. + + When the InfoCard is displayed as a grid item within a grid, you may want items to have the same height for all items. + Set to the `'gridItem'` variant to display the InfoCard with full height suitable for Grid: + `...` + + Changed the InfoCards in '@backstage/plugin-github-actions', '@backstage/plugin-jenkins', '@backstage/plugin-lighthouse' + to pass an optional variant to the corresponding card of the plugin. + + As a result the overview content of the EntityPage shows cards with full height suitable for Grid. + +### Patch Changes + +- 65d722455: Add Pull Request tab to components view. +- 26e69ab1a: Remove cost insights example client from demo app and export from plugin + Create cost insights dev plugin using example client + Make PluginConfig and dependent types public +- e7f5471fd: cleaning up because external plugins have already implemented new api for creating +- Updated dependencies [28edd7d29] +- Updated dependencies [819a70229] +- Updated dependencies [2846ef95c] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [d67c529ab] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [8351ad79b] +- Updated dependencies [30dd11122] +- Updated dependencies [1297dcb3a] +- Updated dependencies [368fd8243] +- Updated dependencies [fb74f1db6] +- Updated dependencies [3472c8be7] +- Updated dependencies [cab473771] +- Updated dependencies [1d0aec70f] +- Updated dependencies [1c60f716e] +- Updated dependencies [a73979d45] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [a3840bed2] +- Updated dependencies [339668995] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [f0aa01bcc] +- Updated dependencies [8d1360aa9] +- Updated dependencies [72f6cda35] +- Updated dependencies [0ee9e9f66] +- Updated dependencies [5c70f3d35] +- Updated dependencies [bb48b9833] +- Updated dependencies [fd8384d7e] +- Updated dependencies [8c2b76e45] +- Updated dependencies [0aecfded0] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [c5ef12926] +- Updated dependencies [8b9c8196f] +- Updated dependencies [2713f28f4] +- Updated dependencies [6a84cb072] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [60d40892c] +- Updated dependencies [cba4e4d97] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [2ebcfac8d] +- Updated dependencies [4fc1d440e] +- Updated dependencies [fa56f4615] +- Updated dependencies [8afce088a] +- Updated dependencies [4512b9967] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [9a3b3dbf1] +- Updated dependencies [e7d4ac7ce] +- Updated dependencies [0b956f21b] +- Updated dependencies [1c8c43756] +- Updated dependencies [0e67c6b40] +- Updated dependencies [26e69ab1a] +- Updated dependencies [97c2cb19b] +- Updated dependencies [02c60b5f8] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [57b54c8ed] +- Updated dependencies [1611c6dbc] +- Updated dependencies [7bbeb049f] + - @backstage/cli@0.2.0 + - @backstage/plugin-api-docs@0.2.0 + - @backstage/plugin-catalog@0.2.0 + - @backstage/plugin-circleci@0.2.0 + - @backstage/plugin-explore@0.2.0 + - @backstage/plugin-gcp-projects@0.2.0 + - @backstage/plugin-github-actions@0.2.0 + - @backstage/plugin-gitops-profiles@0.2.0 + - @backstage/plugin-graphiql@0.2.0 + - @backstage/plugin-jenkins@0.2.0 + - @backstage/plugin-kubernetes@0.2.0 + - @backstage/plugin-lighthouse@0.2.0 + - @backstage/plugin-newrelic@0.2.0 + - @backstage/plugin-register-component@0.2.0 + - @backstage/plugin-rollbar@0.2.0 + - @backstage/plugin-scaffolder@0.2.0 + - @backstage/plugin-sentry@0.2.0 + - @backstage/plugin-tech-radar@0.2.0 + - @backstage/plugin-techdocs@0.2.0 + - @backstage/plugin-welcome@0.2.0 + - @backstage/core@0.2.0 + - @backstage/plugin-cloudbuild@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 + - @backstage/plugin-cost-insights@0.2.0 + - @backstage/plugin-user-settings@0.2.0 + - @backstage/test-utils@0.1.2 diff --git a/packages/app/package.json b/packages/app/package.json index 20b9e24701..529c5d4f63 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,40 +1,44 @@ { "name": "example-app", - "version": "0.1.1-alpha.24", + "version": "0.2.2", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/plugin-api-docs": "^0.1.1-alpha.24", - "@backstage/plugin-catalog": "^0.1.1-alpha.24", - "@backstage/plugin-circleci": "^0.1.1-alpha.24", - "@backstage/plugin-cloudbuild": "^0.1.1-alpha.24", - "@backstage/plugin-cost-insights": "^0.1.1-alpha.24", - "@backstage/plugin-explore": "^0.1.1-alpha.24", - "@backstage/plugin-gcp-projects": "^0.1.1-alpha.24", - "@backstage/plugin-github-actions": "^0.1.1-alpha.24", - "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.24", - "@backstage/plugin-graphiql": "^0.1.1-alpha.24", - "@backstage/plugin-jenkins": "^0.1.1-alpha.24", - "@backstage/plugin-kubernetes": "^0.1.1-alpha.24", - "@backstage/plugin-lighthouse": "^0.1.1-alpha.24", - "@backstage/plugin-newrelic": "^0.1.1-alpha.24", - "@backstage/plugin-register-component": "^0.1.1-alpha.24", - "@backstage/plugin-rollbar": "^0.1.1-alpha.24", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.24", - "@backstage/plugin-sentry": "^0.1.1-alpha.24", - "@backstage/plugin-tech-radar": "^0.1.1-alpha.24", - "@backstage/plugin-techdocs": "^0.1.1-alpha.24", - "@backstage/plugin-welcome": "^0.1.1-alpha.24", - "@backstage/test-utils": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/cli": "^0.3.0", + "@backstage/core": "^0.3.1", + "@backstage/plugin-api-docs": "^0.2.1", + "@backstage/plugin-catalog": "^0.2.2", + "@backstage/plugin-circleci": "^0.2.1", + "@backstage/plugin-cloudbuild": "^0.2.1", + "@backstage/plugin-cost-insights": "^0.4.0", + "@backstage/plugin-explore": "^0.2.1", + "@backstage/plugin-gcp-projects": "^0.2.1", + "@backstage/plugin-github-actions": "^0.2.1", + "@backstage/plugin-gitops-profiles": "^0.2.1", + "@backstage/plugin-graphiql": "^0.2.1", + "@backstage/plugin-jenkins": "^0.3.0", + "@backstage/plugin-kubernetes": "^0.2.1", + "@backstage/plugin-lighthouse": "^0.2.2", + "@backstage/plugin-newrelic": "^0.2.1", + "@backstage/plugin-register-component": "^0.2.1", + "@backstage/plugin-rollbar": "^0.2.2", + "@backstage/plugin-scaffolder": "^0.3.0", + "@backstage/plugin-sentry": "^0.2.2", + "@backstage/plugin-search": "^0.2.0", + "@backstage/plugin-tech-radar": "^0.3.0", + "@backstage/plugin-techdocs": "^0.2.2", + "@backstage/plugin-user-settings": "^0.2.2", + "@backstage/plugin-welcome": "^0.2.1", + "@backstage/test-utils": "^0.1.3", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.0", - "@roadiehq/backstage-plugin-github-pull-requests": "^0.4.3", - "@roadiehq/backstage-plugin-travis-ci": "^0.2.3", + "@roadiehq/backstage-plugin-github-insights": "^0.2.14", + "@roadiehq/backstage-plugin-github-pull-requests": "^0.6.2", + "@roadiehq/backstage-plugin-travis-ci": "^0.2.7", + "@roadiehq/backstage-plugin-buildkite": "^0.1.2", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", @@ -46,7 +50,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@testing-library/cypress": "^6.0.0", + "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -83,14 +87,5 @@ "last 1 safari version" ] }, - "license": "Apache-2.0", - "proxy": { - "/circleci/api": { - "target": "https://circleci.com/api/v1.1", - "changeOrigin": true, - "pathRewrite": { - "^/circleci/api/": "/" - } - } - } + "license": "Apache-2.0" } diff --git a/packages/app/public/index.html b/packages/app/public/index.html index ea9208ca57..77e5c01e19 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -48,6 +48,23 @@ } <%= app.title %> + + <% if (app.googleAnalyticsTrackingId && typeof app.googleAnalyticsTrackingId + === 'string') { %> + + + <% } %> diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 667b14366f..81d08f58a9 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -33,6 +33,7 @@ import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; +import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; import { Route, Routes, Navigate } from 'react-router'; import { EntityPage } from './components/catalog/EntityPage'; @@ -81,6 +82,7 @@ const AppRoutes = () => ( path="/register-component" element={} /> + } /> {...deprecatedAppRoutes} ); diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index c129bebfa2..bd16cd74ef 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -26,17 +26,9 @@ import { } from '@backstage/plugin-graphiql'; import { - TravisCIApi, - travisCIApiRef, -} from '@roadiehq/backstage-plugin-travis-ci'; - -import { - GithubPullRequestsClient, - githubPullRequestsApiRef, -} from '@roadiehq/backstage-plugin-github-pull-requests'; - -import { costInsightsApiRef } from '@backstage/plugin-cost-insights'; -import { ExampleCostInsightsClient } from './plugins/cost-insights'; + costInsightsApiRef, + ExampleCostInsightsClient, +} from '@backstage/plugin-cost-insights'; export const apis = [ createApiFactory({ @@ -59,8 +51,4 @@ export const apis = [ }), createApiFactory(costInsightsApiRef, new ExampleCostInsightsClient()), - - // TODO: move to plugins - createApiFactory(travisCIApiRef, new TravisCIApi()), - createApiFactory(githubPullRequestsApiRef, new GithubPullRequestsClient()), ]; diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 45ffca7801..e947e5c91d 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -35,11 +35,10 @@ import { SidebarDivider, SidebarSearchField, SidebarSpace, - SidebarUserSettings, - DefaultProviderSettings, } from '@backstage/core'; import { NavLink } from 'react-router-dom'; import { graphiQLRouteRef } from '@backstage/plugin-graphiql'; +import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; const useSidebarLogoStyles = makeStyles({ root: { @@ -103,7 +102,7 @@ const Root: FC<{}> = ({ children }) => ( /> - } /> + {children} diff --git a/packages/app/src/components/catalog/EntityPage.test.tsx b/packages/app/src/components/catalog/EntityPage.test.tsx new file mode 100644 index 0000000000..f4dfafc7d3 --- /dev/null +++ b/packages/app/src/components/catalog/EntityPage.test.tsx @@ -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 React from 'react'; +import { CICDSwitcher } from './EntityPage'; +import { UrlPatternDiscovery, ApiProvider, ApiRegistry } from '@backstage/core'; +import { + buildKiteApiRef, + BuildKiteApi, +} from '@roadiehq/backstage-plugin-buildkite'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; + +describe('EntityPage Test', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'ExampleComponent', + annotations: { + 'buildkite.com/project-slug': 'exampleProject/examplePipeline', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + const discoveryApi = UrlPatternDiscovery.compile('http://exampleapi.com'); + + const apis = ApiRegistry.from([ + [buildKiteApiRef, new BuildKiteApi({ discoveryApi })], + ]); + + describe('CICDSwitcher Test', () => { + it('Should render BuildKite View', async () => { + const renderedComponent = await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + expect( + renderedComponent.getByText(/exampleProject\/examplePipeline/), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 393ffe2631..73d84ef1ca 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -40,6 +40,13 @@ import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs'; import { Router as SentryRouter } from '@backstage/plugin-sentry'; import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; +import { + Router as GitHubInsightsRouter, + isPluginApplicableToEntity as isGitHubAvailable, + ReadMeCard, + LanguagesCard, + ReleasesCard, +} from '@roadiehq/backstage-plugin-github-insights'; import React, { ReactNode } from 'react'; import { AboutCard, @@ -54,13 +61,24 @@ import { LastLighthouseAuditCard, isPluginApplicableToEntity as isLighthouseAvailable, } from '@backstage/plugin-lighthouse/'; +import { + Router as PullRequestsRouter, + isPluginApplicableToEntity as isPullRequestsAvailable, + PullRequestsStatsCard, +} from '@roadiehq/backstage-plugin-github-pull-requests'; +import { + Router as BuildKiteRouter, + isPluginApplicableToEntity as isBuildKiteAvailable, +} from '@roadiehq/backstage-plugin-buildkite'; -const CICDSwitcher = ({ entity }: { entity: Entity }) => { +export const CICDSwitcher = ({ entity }: { entity: Entity }) => { // This component is just an example of how you can implement your company's logic in entity page. // You can for example enforce that all components of type 'service' should use GitHubActions switch (true) { case isJenkinsAvailable(entity): return ; + case isBuildKiteAvailable(entity): + return ; case isGitHubActionsAvailable(entity): return ; case isCircleCIAvailable(entity): @@ -93,10 +111,12 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => { let content: ReactNode; switch (true) { case isJenkinsAvailable(entity): - content = ; + content = ; break; case isGitHubActionsAvailable(entity): - content = ; + content = ( + + ); break; case isTravisCIAvailable(entity): content = ; @@ -115,14 +135,30 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => { }; const OverviewContent = ({ entity }: { entity: Entity }) => ( - + - + + {isGitHubAvailable(entity) && ( + <> + + + + + + + + + )} {isLighthouseAvailable(entity) && ( - + + + )} + {isPullRequestsAvailable(entity) && ( + + )} @@ -160,6 +196,16 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( title="Kubernetes" element={} /> + } + /> + } + /> ); @@ -195,6 +241,16 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( title="Kubernetes" element={} /> + } + /> + } + /> ); const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 388bc8d8b6..06ff6b07d3 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -21,6 +21,7 @@ import { githubAuthApiRef, samlAuthApiRef, microsoftAuthApiRef, + oneloginAuthApiRef, } from '@backstage/core'; export const providers = [ @@ -60,4 +61,10 @@ export const providers = [ message: 'Sign In using SAML', apiRef: samlAuthApiRef, }, + { + id: 'onelogin-auth-provider', + title: 'OneLogin', + message: 'Sign In using OneLogin', + apiRef: oneloginAuthApiRef, + }, ]; diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 85c4977d67..d6577b4ce4 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -36,3 +36,7 @@ export { plugin as GcpProjects } from '@backstage/plugin-gcp-projects'; export { plugin as Kubernetes } from '@backstage/plugin-kubernetes'; export { plugin as Cloudbuild } from '@backstage/plugin-cloudbuild'; export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; +export { plugin as GitHubInsights } from '@roadiehq/backstage-plugin-github-insights'; +export { plugin as UserSettings } from '@backstage/plugin-user-settings'; +export { plugin as BuildKite } from '@roadiehq/backstage-plugin-buildkite'; +export { plugin as Search } from '@backstage/plugin-search'; diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts deleted file mode 100644 index c01092dc51..0000000000 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ /dev/null @@ -1,209 +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. - */ -/* eslint-disable no-restricted-imports */ - -import { - CostInsightsApi, - Alert, - Cost, - Duration, - Project, - ProductCost, - Group, -} from '@backstage/plugin-cost-insights'; - -export class ExampleCostInsightsClient implements CostInsightsApi { - private request(_: any, res: any): Promise { - return new Promise(resolve => setTimeout(resolve, 0, res)); - } - - async getUserGroups(userId: string): Promise { - const groups: Group[] = await this.request({ userId }, [ - { id: 'pied-piper' }, - ]); - - return groups; - } - - async getGroupProjects(group: string): Promise { - const projects: Project[] = await this.request({ group }, [ - { id: 'project-a' }, - { id: 'project-b' }, - { id: 'project-c' }, - ]); - - return projects; - } - - async getGroupDailyCost( - group: string, - metric: string | null, - intervals: string, - ): Promise { - const groupDailyCost: Cost = await this.request( - { group, metric, intervals }, - { - id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel - aggregation: [ - { date: '2020-08-01', amount: 75_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-02', amount: 120_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-03', amount: 110_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-04', amount: 90_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-05', amount: 80_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-06', amount: 85_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-07', amount: 82_500 / (metric ? 200_000 : 1) }, - { date: '2020-08-08', amount: 100_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-09', amount: 130_000 / (metric ? 200_000 : 1) }, - { date: '2020-08-10', amount: 140_000 / (metric ? 200_000 : 1) }, - ], - change: { - ratio: 0.86, - amount: 65_000, - }, - trendline: { - slope: 0, - intercept: 90_000, - }, - }, - ); - - return groupDailyCost; - } - - async getProjectDailyCost( - project: string, - metric: string | null, - intervals: string, - ): Promise { - const projectDailyCost: Cost = await this.request( - { project, metric, intervals }, - { - id: 'project-a', - aggregation: [ - { date: '2020-08-01', amount: 1000 }, - { date: '2020-08-02', amount: 2000 }, - { date: '2020-08-03', amount: 3000 }, - { date: '2020-08-04', amount: 4000 }, - { date: '2020-08-05', amount: 5000 }, - { date: '2020-08-06', amount: 6000 }, - { date: '2020-08-07', amount: 7000 }, - { date: '2020-08-08', amount: 8000 }, - { date: '2020-08-09', amount: 9000 }, - { date: '2020-08-10', amount: 10_000 }, - ], - change: { - ratio: 0.5, - amount: 10000, - }, - trendline: { - slope: 0, - intercept: 0, - }, - }, - ); - - return projectDailyCost; - } - - async getProductInsights( - product: string, - group: string, - duration: Duration, - ): Promise { - const productInsights: ProductCost = await this.request( - { product, group, duration }, - { - aggregation: [200_000, 250_000], - change: { - ratio: 0.2, - amount: 50_000, - }, - entities: [ - { - id: null, // entities with null ids will be appear as "Unlabeled" in product panels - aggregation: [45_000, 50_000], - }, - { - id: 'entity-a', - aggregation: [15_000, 20_000], - }, - { - id: 'entity-b', - aggregation: [20_000, 30_000], - }, - { - id: 'entity-c', - aggregation: [18_000, 25_000], - }, - { - id: 'entity-d', - aggregation: [36_000, 42_000], - }, - { - id: 'entity-e', - aggregation: [0, 10_000], - }, - { - id: 'entity-f', - aggregation: [17_000, 19_000], - }, - { - id: 'entity-g', - aggregation: [49_000, 30_000], - }, - { - id: 'entity-h', - aggregation: [0, 34_000], - }, - ], - }, - ); - - return productInsights; - } - - async getAlerts(group: string): Promise { - const alerts: Alert[] = await this.request({ group }, [ - { - id: 'projectGrowth', - project: 'example-project', - periodStart: 'Q1 2020', - periodEnd: 'Q2 2020', - aggregation: [60_000, 120_000], - change: { - ratio: 1, - amount: 60000, - }, - products: [ - { - id: 'Compute Engine', - aggregation: [58_000, 118_000], - }, - { - id: 'Cloud Dataflow', - aggregation: [1200, 1500], - }, - { - id: 'Cloud Storage', - aggregation: [800, 500], - }, - ], - }, - ]); - - return alerts; - } -} diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md new file mode 100644 index 0000000000..4822032c7f --- /dev/null +++ b/packages/backend-common/CHANGELOG.md @@ -0,0 +1,108 @@ +# @backstage/backend-common + +## 0.3.0 + +### Minor Changes + +- 1722cb53c: Added support for loading and validating configuration schemas, as well as declaring config visibility through schemas. + + The new `loadConfigSchema` function exported by `@backstage/config-loader` allows for the collection and merging of configuration schemas from all nearby dependencies of the project. + + A configuration schema is declared using the `https://backstage.io/schema/config-v1` JSON Schema meta schema, which is based on draft07. The only difference to the draft07 schema is the custom `visibility` keyword, which is used to indicate whether the given config value should be visible in the frontend or not. The possible values are `frontend`, `backend`, and `secret`, where `backend` is the default. A visibility of `secret` has the same scope at runtime, but it will be treated with more care in certain contexts, and defining both `frontend` and `secret` for the same value in two different schemas will result in an error during schema merging. + + Packages that wish to contribute configuration schema should declare it in a root `"configSchema"` field in `package.json`. The field can either contain an inlined JSON schema, or a relative path to a schema file. Schema files can be in either `.json` or `.d.ts` format. + + TypeScript configuration schema files should export a single `Config` type, for example: + + ```ts + export interface Config { + app: { + /** + * Frontend root URL + * @visibility frontend + */ + baseUrl: string; + }; + } + ``` + +- 8e2effb53: Refactored UrlReader.readTree to be required and accept (url, options) + +### Patch Changes + +- 1722cb53c: Added configuration schema +- 7b37e6834: Added the integration package +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] + - @backstage/config-loader@0.3.0 + - @backstage/integration@0.1.1 + - @backstage/test-utils@0.1.3 + +## 0.2.1 + +### Patch Changes + +- 33b7300eb: Capture plugin name under the /api/ prefix for http metrics + +## 0.2.0 + +### Minor Changes + +- 5249594c5: Add service discovery interface and implement for single host deployments + + Fixes #1847, #2596 + + Went with an interface similar to the frontend DiscoveryApi, since it's dead simple but still provides a lot of flexibility in the implementation. + + Also ended up with two different methods, one for internal endpoint discovery and one for external. The two use-cases are explained a bit more in the docs, but basically it's service-to-service vs callback URLs. + + This did get me thinking about uniqueness and that we're heading towards a global namespace for backend plugin IDs. That's probably fine, but if we're happy with that we should leverage it a bit more to simplify the backend setup. For example we'd have each plugin provide its own ID and not manually mount on paths in the backend. + + Draft until we're happy with the implementation, then I can add more docs and changelog entry. Also didn't go on a thorough hunt for places where discovery can be used, but I don't think there are many since it's been pretty awkward to do service-to-service communication. + +- 56e4eb589: Make CSP configurable to fix app-backend served app not being able to fetch + + See discussion [here on discord](https://discordapp.com/channels/687207715902193673/687235481154617364/758721460163575850) + +- e37c0a005: Use localhost to fall back to IPv4 if IPv6 isn't available +- f00ca3cb8: Auto-create plugin databases + + Relates to #1598. + + This creates databases for plugins before handing off control to plugins. + + The list of plugins currently need to be hard-coded depending on the installed plugins. A later PR will properly refactor the code to provide a factory pattern where plugins specify what they need, and Knex instances will be provided based on the input. + +- 6579769df: Add the ability to import components from Bitbucket Server to the service catalog +- 8c2b76e45: **BREAKING CHANGE** + + The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed. + Instead, the CLI and backend process now accept one or more `--config` flags to load config files. + + Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded. + If passing any `--config ` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one. + + The old behaviour of for example `APP_ENV=development` can be replicated using the following flags: + + ```bash + --config ../../app-config.yaml --config ../../app-config.development.yaml + ``` + +- 8afce088a: Use APP_ENV before NODE_ENV for determining what config to load +- 7bbeb049f: Change loadBackendConfig to return the config directly + +### Patch Changes + +- 440a17b39: Added new UrlReader interface for reading opaque data from URLs with different providers. + + This new URL reading system is intended as a replacement for the various integrations towards + external systems in the catalog, scaffolder, and techdocs. It is configured via a new top-level + config section called 'integrations'. + + Along with the UrlReader interface is a new UrlReaders class, which exposes static factory + methods for instantiating readers that can read from many different integrations simultaneously. + +- Updated dependencies [8c2b76e45] +- Updated dependencies [ce5512bc0] + - @backstage/config-loader@0.2.0 + - @backstage/test-utils@0.1.2 diff --git a/packages/backend-common/README.md b/packages/backend-common/README.md index 82db0454d2..dc08d89416 100644 --- a/packages/backend-common/README.md +++ b/packages/backend-common/README.md @@ -34,5 +34,5 @@ app.listen(PORT, () => { ## Documentation -- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts new file mode 100644 index 0000000000..7ebcd01e8c --- /dev/null +++ b/packages/backend-common/config.d.ts @@ -0,0 +1,148 @@ +/* + * 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 interface Config { + app: { + baseUrl: string; // defined in core, but repeated here without doc + }; + + backend: { + baseUrl: string; // defined in core, but repeated here without doc + + /** Address that the backend should listen to. */ + listen: + | string + | { + /** Address of the interface that the backend should bind to. */ + address?: string; + /** Port that the backend should listen to. */ + port?: number; + }; + + /** HTTPS configuration for the backend. If omitted the backend will serve HTTP */ + https?: { + /** Certificate configuration or parameters for generating a self-signed certificate */ + certificate?: + | { + /** Algorithm to use to generate a self-signed certificate */ + algorithm: string; + keySize?: number; + days?: number; + } + | { + /** PEM encoded certificate. Use $file to load in a file */ + cert: string; + /** + * PEM encoded certificate key. Use $file to load in a file. + * @visibility secret + */ + key: string; + }; + }; + + /** Database connection configuration, select database type using the `client` field */ + database: + | { + client: 'sqlite3'; + connection: ':memory:' | string; + } + | { + client: 'pg'; + /** + * PostgreSQL connection string or knex configuration object. + * @secret + */ + connection: string | object; + }; + + cors?: { + origin?: string | string[]; + methods?: string | string[]; + allowedHeaders?: string | string[]; + exposedHeaders?: string | string[]; + credentials?: boolean; + maxAge?: number; + preflightContinue?: boolean; + optionsSuccessStatus?: number; + }; + + /** */ + csp?: object; + }; + + /** Configuration for integrations towards various external repository provider systems */ + integrations?: { + /** Integration configuration for Azure */ + azure?: Array<{ + /** The hostname of the given Azure instance */ + host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + }>; + + /** Integration configuration for BitBucket */ + bitbucket?: Array<{ + /** The hostname of the given Bitbucket instance */ + host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** The base url for the BitBucket API, for example https://api.bitbucket.org/2.0 */ + apiBaseUrl?: string; + /** + * The username to use for authenticated requests. + * @visibility secret + */ + username?: string; + /** + * BitBucket app password used to authenticate requests. + * @visibility secret + */ + appPassword?: string; + }>; + + /** Integration configuration for GitHub */ + github?: Array<{ + /** The hostname of the given GitHub instance */ + host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** The base url for the GitHub API, for example https://api.github.com */ + apiBaseUrl?: string; + /** The base url for GitHub raw resources, for example https://raw.githubusercontent.com */ + rawBaseUrl?: string; + }>; + + /** Integration configuration for GitLab */ + gitlab?: Array<{ + /** The hostname of the given GitLab instance */ + host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + }>; + }; +} diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 1c888ab8fe..89150302b5 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.24", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -13,7 +13,7 @@ "homepage": "https://backstage.io", "repository": { "type": "git", - "url": "https://github.com/spotify/backstage", + "url": "https://github.com/backstage/backstage", "directory": "packages/backend-common" }, "keywords": [ @@ -29,26 +29,32 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", - "@backstage/config-loader": "^0.1.1-alpha.24", + "@backstage/cli-common": "^0.1.1", + "@backstage/config": "^0.1.1", + "@backstage/config-loader": "^0.3.0", + "@backstage/integration": "^0.1.1", + "@backstage/test-utils": "^0.1.3", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "compression": "^1.7.4", + "concat-stream": "^2.0.0", "cors": "^2.8.5", + "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-prom-bundle": "^6.1.0", "express-promise-router": "^3.0.3", - "git-url-parse": "^11.2.0", + "fs-extra": "^9.0.1", + "git-url-parse": "^11.4.0", "helmet": "^4.0.0", - "knex": "^0.21.1", + "knex": "^0.21.6", "lodash": "^4.17.15", "logform": "^2.1.1", + "minimist": "^1.2.5", "morgan": "^1.10.0", - "node-fetch": "^2.6.0", "prom-client": "^12.0.0", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", + "tar": "^6.0.5", "winston": "^3.2.1" }, "peerDependencies": { @@ -60,23 +66,31 @@ } }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", "@types/compression": "^1.7.0", + "@types/concat-stream": "^1.6.0", + "@types/fs-extra": "^9.0.3", "@types/http-errors": "^1.6.3", + "@types/minimist": "^1.2.0", + "@types/mock-fs": "^4.13.0", "@types/morgan": "^1.9.0", - "@types/node-fetch": "^2.5.7", + "@types/recursive-readdir": "^2.2.0", "@types/stoppable": "^1.1.0", "@types/supertest": "^2.0.8", + "@types/tar": "^4.0.3", "@types/webpack-env": "^1.15.2", "@types/yaml": "^1.9.7", "get-port": "^5.1.1", "http-errors": "^1.7.3", "jest": "^26.0.1", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", + "mock-fs": "^4.13.0", + "msw": "^0.21.2", + "recursive-readdir": "^2.2.2", "supertest": "^4.0.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 247eb593cc..86beb65805 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -14,18 +14,37 @@ * limitations under the License. */ +import { resolve as resolvePath } from 'path'; +import parseArgs from 'minimist'; +import { Logger } from 'winston'; import { findPaths } from '@backstage/cli-common'; +import { Config, ConfigReader } from '@backstage/config'; import { loadConfig } from '@backstage/config-loader'; +type Options = { + logger: Logger; + // process.argv or any other overrides + argv: string[]; +}; + /** * Load configuration for a Backend */ -export async function loadBackendConfig() { +export async function loadBackendConfig(options: Options): Promise { + const args = parseArgs(options.argv); + const configOpts: string[] = [args.config ?? []].flat(); + + /* eslint-disable-next-line no-restricted-syntax */ const paths = findPaths(__dirname); const configs = await loadConfig({ - env: process.env.NODE_ENV ?? 'development', - rootPaths: [paths.targetRoot, paths.targetDir], - shouldReadSecrets: true, + env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development', + configRoot: paths.targetRoot, + configPaths: configOpts.map(opt => resolvePath(opt)), }); - return configs; + + options.logger.info( + `Loaded config from ${configs.map(c => c.context).join(', ')}`, + ); + + return ConfigReader.fromConfigs(configs); } diff --git a/packages/backend-common/src/database/SingleConnection.test.ts b/packages/backend-common/src/database/SingleConnection.test.ts new file mode 100644 index 0000000000..6278e4ecad --- /dev/null +++ b/packages/backend-common/src/database/SingleConnection.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import { createDatabaseClient } from './connection'; +import { SingleConnectionDatabaseManager } from './SingleConnection'; + +jest.mock('./connection'); + +describe('SingleConnectionDatabaseManager', () => { + const createConfig = (data: any) => + ConfigReader.fromConfigs([ + { + context: '', + data, + }, + ]); + + const defaultConfigOptions = { + backend: { + database: { + client: 'pg', + connection: { + host: 'localhost', + user: 'foo', + password: 'bar', + database: 'foodb', + }, + }, + }, + }; + const defaultConfig = () => createConfig(defaultConfigOptions); + + // This is similar to the ts-jest `mocked` helper. + const mocked = (f: Function) => f as jest.Mock; + + afterEach(() => jest.resetAllMocks()); + + describe('SingleConnectionDatabaseManager.fromConfig', () => { + it('accesses the backend.database key', () => { + const getConfig = jest.fn(); + const config = defaultConfig(); + config.getConfig = getConfig; + + SingleConnectionDatabaseManager.fromConfig(config); + + expect(getConfig.mock.calls[0][0]).toEqual('backend.database'); + }); + }); + + describe('SingleConnectionDatabaseManager.forPlugin', () => { + const manager = SingleConnectionDatabaseManager.fromConfig(defaultConfig()); + + it('connects to a database scoped to the plugin', async () => { + const pluginId = 'test1'; + await manager.forPlugin(pluginId).getClient(); + + expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(1); + + const mockCalls = mocked(createDatabaseClient).mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0].get()).toEqual(defaultConfigOptions.backend.database); + expect(callArgs[1].connection.database).toEqual( + `backstage_plugin_${pluginId}`, + ); + }); + + it('provides different plugins different databases', async () => { + const plugin1Id = 'test1'; + const plugin2Id = 'test2'; + await manager.forPlugin(plugin1Id).getClient(); + await manager.forPlugin(plugin2Id).getClient(); + + expect(mocked(createDatabaseClient)).toHaveBeenCalledTimes(2); + + const mockCalls = mocked(createDatabaseClient).mock.calls; + const plugin1CallArgs = mockCalls[0]; + const plugin2CallArgs = mockCalls[1]; + expect(plugin1CallArgs[1].connection.database).not.toEqual( + plugin2CallArgs[1].connection.database, + ); + }); + }); +}); diff --git a/packages/backend-common/src/database/SingleConnection.ts b/packages/backend-common/src/database/SingleConnection.ts new file mode 100644 index 0000000000..777ced54a0 --- /dev/null +++ b/packages/backend-common/src/database/SingleConnection.ts @@ -0,0 +1,82 @@ +/* + * 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 Knex from 'knex'; +import { Config } from '@backstage/config'; +import { createDatabaseClient, ensureDatabaseExists } from './connection'; +import { PluginDatabaseManager } from './types'; + +/** + * Implements a Database Manager which will automatically create new databases + * for plugins when requested. All requested databases are created with the + * credentials provided; if the database already exists no attempt to create + * the database will be made. + */ +export class SingleConnectionDatabaseManager { + /** + * Creates a new SingleConnectionDatabaseManager instance by reading from the `backend` + * config section, specifically the `.database` key for discovering the management + * database configuration. + * + * @param config The loaded application configuration. + */ + static fromConfig(config: Config): SingleConnectionDatabaseManager { + return new SingleConnectionDatabaseManager( + config.getConfig('backend.database'), + ); + } + + private constructor(private readonly config: Config) {} + + /** + * Generates a PluginDatabaseManager for consumption by plugins. + * + * @param pluginId The plugin that the database manager should be created for. Plugin names should be unique. + */ + forPlugin(pluginId: string): PluginDatabaseManager { + const _this = this; + + return { + getClient(): Promise { + return _this.getDatabase(pluginId); + }, + }; + } + + private async getDatabase(pluginId: string): Promise { + const config = this.config; + const overrides = SingleConnectionDatabaseManager.getDatabaseOverrides( + pluginId, + ); + const overrideConfig = overrides.connection as Knex.ConnectionConfig; + await this.ensureDatabase(overrideConfig.database); + + return createDatabaseClient(config, overrides); + } + + private static getDatabaseOverrides(pluginId: string): Knex.Config { + return { + connection: { + database: `backstage_plugin_${pluginId}`, + }, + }; + } + + private async ensureDatabase(database: string) { + const config = this.config; + await ensureDatabaseExists(config, database); + } +} diff --git a/packages/backend-common/src/database/config.test.ts b/packages/backend-common/src/database/config.test.ts index ab29bc19ba..eb26013b4b 100644 --- a/packages/backend-common/src/database/config.test.ts +++ b/packages/backend-common/src/database/config.test.ts @@ -17,7 +17,19 @@ import { mergeDatabaseConfig } from './config'; describe('config', () => { - describe(mergeDatabaseConfig, () => { + describe('mergeDatabaseConfig', () => { + it('does not mutate the input object', () => { + const input = { + original: 'key', + }; + const override = { + added: 'value', + }; + + mergeDatabaseConfig(input, override); + expect(input).not.toHaveProperty('added'); + }); + it('does not require overrides', () => { expect( mergeDatabaseConfig({ diff --git a/packages/backend-common/src/database/config.ts b/packages/backend-common/src/database/config.ts index 80abc06aa2..af32556c28 100644 --- a/packages/backend-common/src/database/config.ts +++ b/packages/backend-common/src/database/config.ts @@ -19,9 +19,9 @@ import { merge } from 'lodash'; /** * Merges database objects together * - * @param config The base config + * @param config The base config. The input is not modified * @param overrides Any additional overrides */ export function mergeDatabaseConfig(config: any, ...overrides: any[]) { - return merge(config, ...overrides); + return merge({}, config, ...overrides); } diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts index 2cc3a54e06..de0b0e40dc 100644 --- a/packages/backend-common/src/database/connection.test.ts +++ b/packages/backend-common/src/database/connection.test.ts @@ -26,7 +26,7 @@ describe('database connection', () => { }, ]); - describe(createDatabaseClient, () => { + describe('createDatabaseClient', () => { it('returns a postgres connection', () => { expect( createDatabaseClient( diff --git a/packages/backend-common/src/database/index.ts b/packages/backend-common/src/database/index.ts index 38d3d6224b..c81153aa62 100644 --- a/packages/backend-common/src/database/index.ts +++ b/packages/backend-common/src/database/index.ts @@ -15,3 +15,5 @@ */ export * from './connection'; +export * from './types'; +export * from './SingleConnection'; diff --git a/packages/backend-common/src/database/postgres.test.ts b/packages/backend-common/src/database/postgres.test.ts index 82161dc53c..8d139e4481 100644 --- a/packages/backend-common/src/database/postgres.test.ts +++ b/packages/backend-common/src/database/postgres.test.ts @@ -44,7 +44,7 @@ describe('postgres', () => { }, ]); - describe(buildPgDatabaseConfig, () => { + describe('buildPgDatabaseConfig', () => { it('builds a postgres config', () => { const mockConnection = createMockConnection(); @@ -125,7 +125,7 @@ describe('postgres', () => { }); }); - describe(getPgConnectionConfig, () => { + describe('getPgConnectionConfig', () => { it('returns the connection object back', () => { const mockConnection = createMockConnection(); const config = createConfig(mockConnection); @@ -163,7 +163,7 @@ describe('postgres', () => { }); }); - describe(createPgDatabaseClient, () => { + describe('createPgDatabaseClient', () => { it('creates a postgres knex instance', () => { expect( createPgDatabaseClient( @@ -188,8 +188,8 @@ describe('postgres', () => { }); }); - describe(parsePgConnectionString, () => { - it('parses a connection string uri ', () => { + describe('parsePgConnectionString', () => { + it('parses a connection string uri', () => { expect( parsePgConnectionString( 'postgresql://postgres:pass@foobar:5432/dbname?ssl=true', diff --git a/packages/backend-common/src/database/postgres.ts b/packages/backend-common/src/database/postgres.ts index 82e971f081..04bd3615b6 100644 --- a/packages/backend-common/src/database/postgres.ts +++ b/packages/backend-common/src/database/postgres.ts @@ -57,7 +57,7 @@ export function buildPgDatabaseConfig( * Gets the postgres connection config * * @param dbConfig The database config - * @param parseConnectionString Flag to explictly control connection string parsing + * @param parseConnectionString Flag to explicitly control connection string parsing */ export function getPgConnectionConfig( dbConfig: Config, diff --git a/packages/backend-common/src/database/sqlite3.test.ts b/packages/backend-common/src/database/sqlite3.test.ts index dbff5ee354..a3ab331c2d 100644 --- a/packages/backend-common/src/database/sqlite3.test.ts +++ b/packages/backend-common/src/database/sqlite3.test.ts @@ -32,7 +32,7 @@ describe('sqlite3', () => { }, ]); - describe(buildSqliteDatabaseConfig, () => { + describe('buildSqliteDatabaseConfig', () => { it('buidls a string connection', () => { expect(buildSqliteDatabaseConfig(createConfig(':memory:'))).toEqual({ client: 'sqlite3', @@ -72,7 +72,7 @@ describe('sqlite3', () => { }); }); - describe(createSqliteDatabaseClient, () => { + describe('createSqliteDatabaseClient', () => { it('creates an in memory knex instance', () => { expect( createSqliteDatabaseClient( diff --git a/plugins/cost-insights/src/utils/graphs.tsx b/packages/backend-common/src/database/types.ts similarity index 59% rename from plugins/cost-insights/src/utils/graphs.tsx rename to packages/backend-common/src/database/types.ts index 5200b14b1f..7eaa05f173 100644 --- a/plugins/cost-insights/src/utils/graphs.tsx +++ b/packages/backend-common/src/database/types.ts @@ -14,18 +14,17 @@ * limitations under the License. */ -import { - currencyFormatter, - dateFormatter, - lengthyCurrencyFormatter, -} from './formatters'; +import knex from 'knex'; -export function formatGraphValue(value: number) { - if (value < 1) { - return lengthyCurrencyFormatter.format(value); - } - return currencyFormatter.format(value); +/** + * The PluginDatabaseManager manages access to databases that Plugins get. + */ +export interface PluginDatabaseManager { + /** + * getClient provides backend plugins database connections for itself. + * + * The purpose of this method is to allow plugins to get isolated data + * stores so that plugins are discouraged from database integration. + */ + getClient(): Promise; } - -export const overviewGraphTickFormatter = (millis: string | number) => - typeof millis === 'number' ? dateFormatter.format(millis) : millis; diff --git a/packages/backend-common/src/paths.ts b/packages/backend-common/src/paths.ts index 402c0e2252..262be366f6 100644 --- a/packages/backend-common/src/paths.ts +++ b/packages/backend-common/src/paths.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -/* eslint-disable @typescript-eslint/camelcase */ - import { resolve as resolvePath } from 'path'; /** diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index c9e1fc5bc7..6efc7641b8 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -19,14 +19,18 @@ import { setupServer } from 'msw/node'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '../logging'; import { AzureUrlReader } from './AzureUrlReader'; +import { msw } from '@backstage/test-utils'; +import { ReadTreeResponseFactory } from './tree'; const logger = getVoidLogger(); +const treeResponseFactory = ReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); + describe('AzureUrlReader', () => { const worker = setupServer(); - - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterAll(() => worker.close()); + msw.setupDefaultHandlers(worker); beforeEach(() => { worker.use( @@ -41,7 +45,6 @@ describe('AzureUrlReader', () => { ), ); }); - afterEach(() => worker.resetHandlers()); const createConfig = (token?: string) => new ConfigReader( @@ -89,7 +92,11 @@ describe('AzureUrlReader', () => { }), }, ])('should handle happy path %#', async ({ url, config, response }) => { - const [{ reader }] = AzureUrlReader.factory({ config, logger }); + const [{ reader }] = AzureUrlReader.factory({ + config, + logger, + treeResponseFactory, + }); const data = await reader.read(url); const res = await JSON.parse(data.toString('utf-8')); @@ -117,7 +124,11 @@ describe('AzureUrlReader', () => { }, ])('should handle error path %#', async ({ url, config, error }) => { await expect(async () => { - const [{ reader }] = AzureUrlReader.factory({ config, logger }); + const [{ reader }] = AzureUrlReader.factory({ + config, + logger, + treeResponseFactory, + }); await reader.read(url); }).rejects.toThrow(error); }); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 28fbf25eea..db8b738667 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -14,49 +14,27 @@ * limitations under the License. */ -import fetch, { RequestInit, HeadersInit, Response } from 'node-fetch'; -import { Config } from '@backstage/config'; +import { + AzureIntegrationConfig, + readAzureIntegrationConfigs, +} from '@backstage/integration'; +import fetch from 'cross-fetch'; import { NotFoundError } from '../errors'; -import { ReaderFactory, UrlReader } from './types'; - -type Options = { - // TODO: added here for future support, but we only allow dev.azure.com for now - host: string; - token?: string; -}; - -function readConfig(config: Config): Options[] { - const optionsArr = Array(); - - const providerConfigs = - config.getOptionalConfigArray('integrations.azure') ?? []; - - for (const providerConfig of providerConfigs) { - const host = providerConfig.getOptionalString('host') ?? 'dev.azure.com'; - const token = providerConfig.getOptionalString('token'); - - optionsArr.push({ host, token }); - } - - // As a convenience we always make sure there's at least an unauthenticated - // reader for public azure repos. - if (!optionsArr.some(p => p.host === 'dev.azure.com')) { - optionsArr.push({ host: 'dev.azure.com' }); - } - - return optionsArr; -} +import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; export class AzureUrlReader implements UrlReader { static factory: ReaderFactory = ({ config }) => { - return readConfig(config).map(options => { + const configs = readAzureIntegrationConfigs( + config.getOptionalConfigArray('integrations.azure') ?? [], + ); + return configs.map(options => { const reader = new AzureUrlReader(options); const predicate = (url: URL) => url.host === options.host; return { reader, predicate }; }); }; - constructor(private readonly options: Options) { + constructor(private readonly options: AzureIntegrationConfig) { if (options.host !== 'dev.azure.com') { throw Error( `Azure integration currently only supports 'dev.azure.com', tried to use host '${options.host}'`, @@ -76,7 +54,7 @@ export class AzureUrlReader implements UrlReader { // for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html if (response.ok && response.status !== 203) { - return response.buffer(); + return Buffer.from(await response.text()); } const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; @@ -86,6 +64,10 @@ export class AzureUrlReader implements UrlReader { throw new Error(message); } + readTree(): Promise { + throw new Error('AzureUrlReader does not implement readTree'); + } + // Converts // from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents // to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch} diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index c3e61fb821..01744db28a 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -14,140 +14,105 @@ * limitations under the License. */ -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { ConfigReader } from '@backstage/config'; -import { getVoidLogger } from '../logging'; -import { BitbucketUrlReader } from './BitbucketUrlReader'; - -const logger = getVoidLogger(); +import { BitbucketIntegrationConfig } from '@backstage/integration'; +import { + BitbucketUrlReader, + getApiRequestOptions, + getApiUrl, +} from './BitbucketUrlReader'; describe('BitbucketUrlReader', () => { - const worker = setupServer(); + describe('getApiRequestOptions', () => { + it('inserts a token when needed', () => { + const withToken: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + token: 'A', + }; + const withoutToken: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getApiRequestOptions(withToken).headers as any).Authorization, + ).toEqual('Bearer A'); + expect( + (getApiRequestOptions(withoutToken).headers as any).Authorization, + ).toBeUndefined(); + }); - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterAll(() => worker.close()); + it('insert basic auth when needed', () => { + const withUsernameAndPassword: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + username: 'some-user', + appPassword: 'my-secret', + }; + const withoutUsernameAndPassword: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getApiRequestOptions(withUsernameAndPassword).headers as any) + .Authorization, + ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); + expect( + (getApiRequestOptions(withoutUsernameAndPassword).headers as any) + .Authorization, + ).toBeUndefined(); + }); + }); - beforeEach(() => { - worker.use( - rest.get('*', (req, res, ctx) => - res( - ctx.status(200), - ctx.json({ - url: req.url.toString(), - headers: req.headers.getAllHeaders(), - }), + describe('getApiUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' }; + expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); + }); + it('happy path for Bitbucket Cloud', () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + expect( + getApiUrl( + 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', + config, ), - ), - ); - }); - afterEach(() => worker.resetHandlers()); - - const createConfig = (username?: string, appPassword?: string) => - new ConfigReader( - { - integrations: { - bitbucket: [ - { - host: 'bitbucket.org', - username: username, - appPassword: appPassword, - }, - ], - }, - }, - 'test-config', - ); - - it.each([ - { - url: - 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', - config: createConfig(), - response: expect.objectContaining({ - url: + ).toEqual( + new URL( 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', - }), - }, - { - url: - 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', - config: createConfig('some-user', 'my-secret'), - response: expect.objectContaining({ - headers: expect.objectContaining({ - authorization: 'Basic c29tZS11c2VyOm15LXNlY3JldA==', - }), - }), - }, - { - url: - 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', - config: createConfig(), - response: expect.objectContaining({ - headers: expect.not.objectContaining({ - authorization: expect.anything(), - }), - }), - }, - { - url: - 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', - config: createConfig(undefined, 'only-password-provided'), - response: expect.objectContaining({ - headers: expect.not.objectContaining({ - authorization: expect.anything(), - }), - }), - }, - ])('should handle happy path %#', async ({ url, config, response }) => { - const [{ reader }] = BitbucketUrlReader.factory({ config, logger }); - - const data = await reader.read(url); - const res = await JSON.parse(data.toString('utf-8')); - expect(res).toEqual(response); + ), + ); + }); + it('happy path for Bitbucket Server', () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0', + }; + expect( + getApiUrl( + 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml', + config, + ), + ).toEqual( + new URL( + 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml', + ), + ); + }); }); - it.each([ - { - url: 'https://api.com/a/b/blob/master/path/to/c.yaml', - config: createConfig(), - error: - 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Bitbucket URL or Invalid file path', - }, - { - url: 'com/a/b/blob/master/path/to/c.yaml', - config: createConfig(), - error: - 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', - }, - { - url: '', - config: createConfig('', ''), - error: - "Invalid type in config for key 'integrations.bitbucket[0].username' in 'test-config', got empty-string, wanted string", - }, - { - url: '', - config: createConfig('only-user-provided', ''), - error: - "Invalid type in config for key 'integrations.bitbucket[0].appPassword' in 'test-config', got empty-string, wanted string", - }, - { - url: '', - config: createConfig('', 'only-password-provided'), - error: - "Invalid type in config for key 'integrations.bitbucket[0].username' in 'test-config', got empty-string, wanted string", - }, - { - url: '', - config: createConfig('only-user-provided', undefined), - error: - "Missing required config value at 'integrations.bitbucket[0].appPassword'", - }, - ])('should handle error path %#', async ({ url, config, error }) => { - await expect(async () => { - const [{ reader }] = BitbucketUrlReader.factory({ config, logger }); - await reader.read(url); - }).rejects.toThrow(error); + describe('implementation', () => { + it('rejects unknown targets', async () => { + const processor = new BitbucketUrlReader({ + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }); + await expect( + processor.read('https://not.bitbucket.com/apa'), + ).rejects.toThrow( + 'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path', + ); + }); }); }); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index e2576eed47..9694c1d987 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -14,149 +14,140 @@ * limitations under the License. */ -import fetch, { RequestInit, HeadersInit, Response } from 'node-fetch'; -import { Config } from '@backstage/config'; -import { ReaderFactory, UrlReader } from './types'; +import { + BitbucketIntegrationConfig, + readBitbucketIntegrationConfigs, +} from '@backstage/integration'; +import fetch from 'cross-fetch'; +import parseGitUri from 'git-url-parse'; import { NotFoundError } from '../errors'; +import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; -type Options = { - // TODO: added here for future support, but we only allow bitbucket.org for now - host: string; - auth?: { - username: string; - appPassword: string; +export function getApiRequestOptions( + provider: BitbucketIntegrationConfig, +): RequestInit { + const headers: HeadersInit = {}; + + if (provider.token) { + headers.Authorization = `Bearer ${provider.token}`; + } else if (provider.username && provider.appPassword) { + headers.Authorization = `Basic ${Buffer.from( + `${provider.username}:${provider.appPassword}`, + 'utf8', + ).toString('base64')}`; + } + + return { + headers, }; -}; - -function readConfig(config: Config): Options[] { - const optionsArr = Array(); - - const providerConfigs = - config.getOptionalConfigArray('integrations.bitbucket') ?? []; - - for (const providerConfig of providerConfigs) { - const host = providerConfig.getOptionalString('host') ?? 'bitbucket.org'; - - let auth; - if (providerConfig.has('username')) { - const username = providerConfig.getString('username'); - const appPassword = providerConfig.getString('appPassword'); - auth = { username, appPassword }; - } - - optionsArr.push({ host, auth }); - } - - // As a convenience we always make sure there's at least an unauthenticated - // reader for public bitbucket repos. - if (!optionsArr.some(p => p.host === 'bitbucket.org')) { - optionsArr.push({ host: 'bitbucket.org' }); - } - - return optionsArr; } +// Converts for example +// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml +// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml +export function getApiUrl( + target: string, + provider: BitbucketIntegrationConfig, +): URL { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); + if ( + !owner || + !name || + (filepathtype !== 'browse' && + filepathtype !== 'raw' && + filepathtype !== 'src') + ) { + throw new Error('Invalid Bitbucket URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + + if (provider.host === 'bitbucket.org') { + if (!ref) { + throw new Error('Invalid Bitbucket URL or file path'); + } + return new URL( + `${provider.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`, + ); + } + return new URL( + `${provider.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`, + ); + } catch (e) { + throw new Error(`Incorrect URL: ${target}, ${e}`); + } +} + +/** + * A processor that adds the ability to read files from Bitbucket v1 and v2 APIs, such as + * the one exposed by Bitbucket Cloud itself. + */ export class BitbucketUrlReader implements UrlReader { + private readonly config: BitbucketIntegrationConfig; + static factory: ReaderFactory = ({ config }) => { - return readConfig(config).map(options => { - const reader = new BitbucketUrlReader(options); - const predicate = (url: URL) => url.host === options.host; + const configs = readBitbucketIntegrationConfigs( + config.getOptionalConfigArray('integrations.bitbucket') ?? [], + ); + return configs.map(provider => { + const reader = new BitbucketUrlReader(provider); + const predicate = (url: URL) => url.host === provider.host; return { reader, predicate }; }); }; - constructor(private readonly options: Options) { - if (options.host !== 'bitbucket.org') { - throw Error( - `Bitbucket integration currently only supports 'bitbucket.org', tried to use host '${options.host}'`, + constructor(config: BitbucketIntegrationConfig) { + const { host, apiBaseUrl, token, username, appPassword } = config; + + if (!apiBaseUrl) { + throw new Error( + `Bitbucket integration for '${host}' must configure an explicit apiBaseUrl`, ); } + + if (!token && username && !appPassword) { + throw new Error( + `Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`, + ); + } + + this.config = config; } async read(url: string): Promise { - const builtUrl = this.buildRawUrl(url); + const bitbucketUrl = getApiUrl(url, this.config); + + const options = getApiRequestOptions(this.config); let response: Response; try { - response = await fetch(builtUrl.toString(), this.getRequestOptions()); + response = await fetch(bitbucketUrl.toString(), options); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } if (response.ok) { - return response.buffer(); + return Buffer.from(await response.text()); } - const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; + const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`; if (response.status === 404) { throw new NotFoundError(message); } throw new Error(message); } - // Converts - // from: https://bitbucket.org/orgname/reponame/src/master/file.yaml - // to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml - private buildRawUrl(target: string): URL { - try { - const url = new URL(target); - - const [ - empty, - userOrOrg, - repoName, - srcKeyword, - ref, - ...restOfPath - ] = url.pathname.split('/'); - - if ( - url.hostname !== 'bitbucket.org' || - empty !== '' || - userOrOrg === '' || - repoName === '' || - srcKeyword !== 'src' - ) { - throw new Error('Wrong Bitbucket URL or Invalid file path'); - } - - // transform to api - url.pathname = [ - empty, - '2.0', - 'repositories', - userOrOrg, - repoName, - 'src', - ref, - ...restOfPath, - ].join('/'); - url.hostname = 'api.bitbucket.org'; - url.protocol = 'https'; - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } - - private getRequestOptions(): RequestInit { - const headers: HeadersInit = {}; - - if (this.options.auth) { - headers.Authorization = `Basic ${Buffer.from( - `${this.options.auth.username}:${this.options.auth.appPassword}`, - 'utf8', - ).toString('base64')}`; - } - - return { - headers, - }; + readTree(): Promise { + throw new Error('BitbucketUrlReader does not implement readTree'); } toString() { - const { host, auth } = this.options; - return `bitbucket{host=${host},authed=${Boolean(auth)}}`; + const { host, token, username, appPassword } = this.config; + let authed = Boolean(token); + if (!authed) { + authed = Boolean(username && appPassword); + } + return `bitbucket{host=${host},authed=${authed}}`; } } diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index ea56f2182d..1d1784590c 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import fetch, { Response } from 'node-fetch'; +import fetch from 'cross-fetch'; import { NotFoundError } from '../errors'; -import { UrlReader } from './types'; +import { ReadTreeResponse, UrlReader } from './types'; /** * A UrlReader that does a plain fetch of the URL. @@ -31,7 +31,7 @@ export class FetchUrlReader implements UrlReader { } if (response.ok) { - return response.buffer(); + return Buffer.from(await response.text()); } const message = `could not read ${url}, ${response.status} ${response.statusText}`; @@ -41,6 +41,10 @@ export class FetchUrlReader implements UrlReader { throw new Error(message); } + readTree(): Promise { + throw new Error('FetchUrlReader does not implement readTree'); + } + toString() { return 'fetch{}'; } diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 8df464db9f..eb87d339ea 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -15,32 +15,41 @@ */ import { ConfigReader } from '@backstage/config'; +import { GitHubIntegrationConfig } from '@backstage/integration'; +import { msw } from '@backstage/test-utils'; +import fs from 'fs'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import path from 'path'; import { getApiRequestOptions, getApiUrl, getRawRequestOptions, getRawUrl, GithubUrlReader, - ProviderConfig, - readConfig, } from './GithubUrlReader'; +import { ReadTreeResponseFactory } from './tree'; + +const treeResponseFactory = ReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); describe('GithubUrlReader', () => { describe('getApiRequestOptions', () => { it('sets the correct API version', () => { - const config: ProviderConfig = { host: '', apiBaseUrl: '' }; + const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; expect((getApiRequestOptions(config).headers as any).Accept).toEqual( 'application/vnd.github.v3.raw', ); }); it('inserts a token when needed', () => { - const withToken: ProviderConfig = { + const withToken: GitHubIntegrationConfig = { host: '', apiBaseUrl: '', token: 'A', }; - const withoutToken: ProviderConfig = { + const withoutToken: GitHubIntegrationConfig = { host: '', apiBaseUrl: '', }; @@ -55,12 +64,12 @@ describe('GithubUrlReader', () => { describe('getRawRequestOptions', () => { it('inserts a token when needed', () => { - const withToken: ProviderConfig = { + const withToken: GitHubIntegrationConfig = { host: '', rawBaseUrl: '', token: 'A', }; - const withoutToken: ProviderConfig = { + const withoutToken: GitHubIntegrationConfig = { host: '', rawBaseUrl: '', }; @@ -75,12 +84,12 @@ describe('GithubUrlReader', () => { describe('getApiUrl', () => { it('rejects targets that do not look like URLs', () => { - const config: ProviderConfig = { host: '', apiBaseUrl: '' }; + const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); }); it('happy path for github', () => { - const config: ProviderConfig = { + const config: GitHubIntegrationConfig = { host: 'github.com', apiBaseUrl: 'https://api.github.com', }; @@ -107,7 +116,7 @@ describe('GithubUrlReader', () => { }); it('happy path for ghe', () => { - const config: ProviderConfig = { + const config: GitHubIntegrationConfig = { host: 'ghe.mycompany.net', apiBaseUrl: 'https://ghe.mycompany.net/api/v3', }; @@ -126,12 +135,12 @@ describe('GithubUrlReader', () => { describe('getRawUrl', () => { it('rejects targets that do not look like URLs', () => { - const config: ProviderConfig = { host: '', apiBaseUrl: '' }; + const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); }); it('happy path for github', () => { - const config: ProviderConfig = { + const config: GitHubIntegrationConfig = { host: 'github.com', rawBaseUrl: 'https://raw.githubusercontent.com', }; @@ -148,7 +157,7 @@ describe('GithubUrlReader', () => { }); it('happy path for ghe', () => { - const config: ProviderConfig = { + const config: GitHubIntegrationConfig = { host: 'ghe.mycompany.net', rawBaseUrl: 'https://ghe.mycompany.net/raw', }; @@ -163,66 +172,15 @@ describe('GithubUrlReader', () => { }); }); - describe('readConfig', () => { - function config( - providers: { host: string; apiBaseUrl?: string; token?: string }[], - ) { - return ConfigReader.fromConfigs([ - { - context: '', - data: { - integrations: { github: providers }, - }, - }, - ]); - } - - it('adds a default GitHub entry when missing', () => { - const output = readConfig(config([])); - expect(output).toEqual([ - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - rawBaseUrl: 'https://raw.githubusercontent.com', - }, - ]); - }); - - it('injects the correct GitHub API base URL when missing', () => { - const output = readConfig(config([{ host: 'github.com' }])); - expect(output).toEqual([ - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - rawBaseUrl: 'https://raw.githubusercontent.com', - }, - ]); - }); - - it('rejects custom targets with no base URLs', () => { - expect(() => readConfig(config([{ host: 'ghe.company.com' }]))).toThrow( - "GitHub integration for 'ghe.company.com' must configure an explicit apiBaseUrl and rawBaseUrl", - ); - }); - - it('rejects funky configs', () => { - expect(() => readConfig(config([{ host: 7 } as any]))).toThrow(/host/); - expect(() => readConfig(config([{ token: 7 } as any]))).toThrow(/token/); - expect(() => - readConfig(config([{ host: 'github.com', apiBaseUrl: 7 } as any])), - ).toThrow(/apiBaseUrl/); - expect(() => - readConfig(config([{ host: 'github.com', token: 7 } as any])), - ).toThrow(/token/); - }); - }); - describe('implementation', () => { it('rejects unknown targets', async () => { - const processor = new GithubUrlReader({ - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }); + const processor = new GithubUrlReader( + { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }, + { treeResponseFactory }, + ); await expect( processor.read('https://not.github.com/apa'), ).rejects.toThrow( @@ -230,4 +188,88 @@ describe('GithubUrlReader', () => { ); }); }); + + describe('readTree', () => { + const worker = setupServer(); + + msw.setupDefaultHandlers(worker); + + const repoBuffer = fs.readFileSync( + path.resolve('src', 'reading', '__fixtures__', 'repo.tar.gz'), + ); + + beforeEach(() => { + worker.use( + rest.get( + 'https://github.com/backstage/mock/archive/repo.tar.gz', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.body(repoBuffer), + ), + ), + ); + }); + + it('returns the wanted files from an archive', async () => { + const processor = new GithubUrlReader( + { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }, + { treeResponseFactory }, + ); + + const response = await processor.readTree( + 'https://github.com/backstage/mock/tree/repo', + ); + + const files = await response.files(); + + expect(files.length).toBe(2); + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); + + expect(mkDocsFile.toString()).toBe('site_name: Test\n'); + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('must specify a branch', async () => { + const processor = new GithubUrlReader( + { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }, + { treeResponseFactory }, + ); + + await expect( + processor.readTree('https://github.com/backstage/mock'), + ).rejects.toThrow( + 'GitHub URL must contain branch to be able to fetch tree', + ); + }); + + it('returns the wanted files from an archive with a subpath', async () => { + const processor = new GithubUrlReader( + { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }, + { treeResponseFactory }, + ); + + const response = await processor.readTree( + 'https://github.com/backstage/mock/tree/repo/docs', + ); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + }); }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index e5bed6dd26..2fbaa0b32b 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -14,52 +14,25 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; +import { + GitHubIntegrationConfig, + readGitHubIntegrationConfigs, +} from '@backstage/integration'; +import fetch from 'cross-fetch'; import parseGitUri from 'git-url-parse'; -import fetch, { HeadersInit, RequestInit, Response } from 'node-fetch'; -import { NotFoundError } from '../errors'; -import { ReaderFactory, UrlReader } from './types'; +import { Readable } from 'stream'; +import { InputError, NotFoundError } from '../errors'; +import { ReadTreeResponseFactory } from './tree'; +import { + ReaderFactory, + ReadTreeOptions, + ReadTreeResponse, + UrlReader, +} from './types'; -/** - * The configuration parameters for a single GitHub API provider. - */ -export type ProviderConfig = { - /** - * The host of the target that this matches on, e.g. "github.com" - */ - host: string; - - /** - * The base URL of the API of this provider, e.g. "https://api.github.com", - * with no trailing slash. - * - * May be omitted specifically for GitHub; then it will be deduced. - * - * The API will always be preferred if both its base URL and a token are - * present. - */ - apiBaseUrl?: string; - - /** - * The base URL of the raw fetch endpoint of this provider, e.g. - * "https://raw.githubusercontent.com", with no trailing slash. - * - * May be omitted specifically for GitHub; then it will be deduced. - * - * The API will always be preferred if both its base URL and a token are - * present. - */ - rawBaseUrl?: string; - - /** - * The authorization token to use for requests to this provider. - * - * If no token is specified, anonymous access is used. - */ - token?: string; -}; - -export function getApiRequestOptions(provider: ProviderConfig): RequestInit { +export function getApiRequestOptions( + provider: GitHubIntegrationConfig, +): RequestInit { const headers: HeadersInit = { Accept: 'application/vnd.github.v3.raw', }; @@ -73,7 +46,9 @@ export function getApiRequestOptions(provider: ProviderConfig): RequestInit { }; } -export function getRawRequestOptions(provider: ProviderConfig): RequestInit { +export function getRawRequestOptions( + provider: GitHubIntegrationConfig, +): RequestInit { const headers: HeadersInit = {}; if (provider.token) { @@ -88,7 +63,10 @@ export function getRawRequestOptions(provider: ProviderConfig): RequestInit { // Converts for example // from: https://github.com/a/b/blob/branchname/path/to/c.yaml // to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname -export function getApiUrl(target: string, provider: ProviderConfig): URL { +export function getApiUrl( + target: string, + provider: GitHubIntegrationConfig, +): URL { try { const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); @@ -113,7 +91,10 @@ export function getApiUrl(target: string, provider: ProviderConfig): URL { // Converts for example // from: https://github.com/a/b/blob/branchname/c.yaml // to: https://raw.githubusercontent.com/a/b/branchname/c.yaml -export function getRawUrl(target: string, provider: ProviderConfig): URL { +export function getRawUrl( + target: string, + provider: GitHubIntegrationConfig, +): URL { try { const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); @@ -135,70 +116,31 @@ export function getRawUrl(target: string, provider: ProviderConfig): URL { } } -export function readConfig(config: Config): ProviderConfig[] { - const providers: ProviderConfig[] = []; - - const providerConfigs = - config.getOptionalConfigArray('integrations.github') ?? []; - - // First read all the explicit providers - for (const providerConfig of providerConfigs) { - const host = providerConfig.getOptionalString('host') ?? 'github.com'; - let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl'); - let rawBaseUrl = providerConfig.getOptionalString('rawBaseUrl'); - const token = providerConfig.getOptionalString('token'); - - if (apiBaseUrl) { - apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); - } else if (host === 'github.com') { - apiBaseUrl = 'https://api.github.com'; - } - - if (rawBaseUrl) { - rawBaseUrl = rawBaseUrl.replace(/\/+$/, ''); - } else if (host === 'github.com') { - rawBaseUrl = 'https://raw.githubusercontent.com'; - } - - if (!apiBaseUrl && !rawBaseUrl) { - throw new Error( - `GitHub integration for '${host}' must configure an explicit apiBaseUrl and rawBaseUrl`, - ); - } - - providers.push({ host, apiBaseUrl, rawBaseUrl, token }); - } - - // If no explicit github.com provider was added, put one in the list as - // a convenience - if (!providers.some(p => p.host === 'github.com')) { - providers.push({ - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - rawBaseUrl: 'https://raw.githubusercontent.com', - }); - } - - return providers; -} - /** * A processor that adds the ability to read files from GitHub v3 APIs, such as * the one exposed by GitHub itself. */ export class GithubUrlReader implements UrlReader { - private config: ProviderConfig; - - static factory: ReaderFactory = ({ config }) => { - return readConfig(config).map(provider => { - const reader = new GithubUrlReader(provider); + static factory: ReaderFactory = ({ config, treeResponseFactory }) => { + const configs = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + ); + return configs.map(provider => { + const reader = new GithubUrlReader(provider, { treeResponseFactory }); const predicate = (url: URL) => url.host === provider.host; return { reader, predicate }; }); }; - constructor(config: ProviderConfig) { - this.config = config; + constructor( + private readonly config: GitHubIntegrationConfig, + private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + ) { + if (!config.apiBaseUrl && !config.rawBaseUrl) { + throw new Error( + `GitHub integration for '${config.host}' must configure an explicit apiBaseUrl and rawBaseUrl`, + ); + } } async read(url: string): Promise { @@ -219,7 +161,7 @@ export class GithubUrlReader implements UrlReader { } if (response.ok) { - return response.buffer(); + return Buffer.from(await response.text()); } const message = `${url} could not be read as ${ghUrl}, ${response.status} ${response.statusText}`; @@ -229,6 +171,51 @@ export class GithubUrlReader implements UrlReader { throw new Error(message); } + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { + const { + name: repoName, + ref, + protocol, + source, + full_name, + filepath, + } = parseGitUri(url); + + if (!ref) { + // TODO(Rugvip): We should add support for defaulting to the default branch + throw new InputError( + 'GitHub URL must contain branch to be able to fetch tree', + ); + } + + // TODO(Rugvip): use API to fetch URL instead + const response = await fetch( + new URL( + `${protocol}://${source}/${full_name}/archive/${ref}.tar.gz`, + ).toString(), + ); + if (!response.ok) { + const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + const path = `${repoName}-${ref}/${filepath}`; + + return this.deps.treeResponseFactory.fromArchive({ + // TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want + // to stick to using that in exclusively backend code. + stream: (response.body as unknown) as Readable, + path, + filter: options?.filter, + }); + } + toString() { const { host, token } = this.config; return `github{host=${host},authed=${Boolean(token)}}`; diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 09da9c4e0a..3a76c0631b 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -19,14 +19,19 @@ import { setupServer } from 'msw/node'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; +import { msw } from '@backstage/test-utils'; +import { ReadTreeResponseFactory } from './tree'; const logger = getVoidLogger(); +const treeResponseFactory = ReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); + describe('GitlabUrlReader', () => { const worker = setupServer(); - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterAll(() => worker.close()); + msw.setupDefaultHandlers(worker); beforeEach(() => { worker.use( @@ -44,7 +49,6 @@ describe('GitlabUrlReader', () => { ), ); }); - afterEach(() => worker.resetHandlers()); const createConfig = (token?: string) => new ConfigReader( @@ -99,7 +103,11 @@ describe('GitlabUrlReader', () => { }), }, ])('should handle happy path %#', async ({ url, config, response }) => { - const [{ reader }] = GitlabUrlReader.factory({ config, logger }); + const [{ reader }] = GitlabUrlReader.factory({ + config, + logger, + treeResponseFactory, + }); const data = await reader.read(url); const res = await JSON.parse(data.toString('utf-8')); @@ -115,7 +123,11 @@ describe('GitlabUrlReader', () => { }, ])('should handle error path %#', async ({ url, config, error }) => { await expect(async () => { - const [{ reader }] = GitlabUrlReader.factory({ config, logger }); + const [{ reader }] = GitlabUrlReader.factory({ + config, + logger, + treeResponseFactory, + }); await reader.read(url); }).rejects.toThrow(error); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 0e430d650c..e2d3edfea2 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -14,48 +14,27 @@ * limitations under the License. */ -import fetch, { RequestInit, Response } from 'node-fetch'; -import { Config } from '@backstage/config'; +import { + GitLabIntegrationConfig, + readGitLabIntegrationConfigs, +} from '@backstage/integration'; +import fetch from 'cross-fetch'; import { NotFoundError } from '../errors'; -import { ReaderFactory, UrlReader } from './types'; - -type Options = { - host: string; - token?: string; -}; - -function readConfig(config: Config): Options[] { - const optionsArr = Array(); - - const providerConfigs = - config.getOptionalConfigArray('integrations.gitlab') ?? []; - - for (const providerConfig of providerConfigs) { - const host = providerConfig.getOptionalString('host') ?? 'gitlab.com'; - const token = providerConfig.getOptionalString('token'); - - optionsArr.push({ host, token }); - } - - // As a convenience we always make sure there's at least an unauthenticated - // reader for public gitlab repos. - if (!optionsArr.some(p => p.host === 'gitlab.com')) { - optionsArr.push({ host: 'gitlab.com' }); - } - - return optionsArr; -} +import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; export class GitlabUrlReader implements UrlReader { static factory: ReaderFactory = ({ config }) => { - return readConfig(config).map(options => { + const configs = readGitLabIntegrationConfigs( + config.getOptionalConfigArray('integrations.gitlab') ?? [], + ); + return configs.map(options => { const reader = new GitlabUrlReader(options); const predicate = (url: URL) => url.host === options.host; return { reader, predicate }; }); }; - constructor(private readonly options: Options) {} + constructor(private readonly options: GitLabIntegrationConfig) {} async read(url: string): Promise { // TODO(Rugvip): merged the old GitlabReaderProcessor in here and used @@ -77,7 +56,7 @@ export class GitlabUrlReader implements UrlReader { } if (response.ok) { - return response.buffer(); + return Buffer.from(await response.text()); } const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; @@ -87,6 +66,10 @@ export class GitlabUrlReader implements UrlReader { throw new Error(message); } + readTree(): Promise { + throw new Error('GitlabUrlReader does not implement readTree'); + } + // Converts // from: https://gitlab.example.com/a/b/blob/master/c.yaml // to: https://gitlab.example.com/a/b/raw/master/c.yaml @@ -129,9 +112,9 @@ export class GitlabUrlReader implements UrlReader { try { const url = new URL(target); - const branchAndfilePath = url.pathname.split('/-/blob/')[1]; + const branchAndFilePath = url.pathname.split('/-/blob/')[1]; - const [branch, ...filePath] = branchAndfilePath.split('/'); + const [branch, ...filePath] = branchAndFilePath.split('/'); url.pathname = [ '/api/v4/projects', diff --git a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts index 7654cc8aac..465c125fda 100644 --- a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts +++ b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import { UrlReader, UrlReaderPredicateTuple } from './types'; +import { + ReadTreeOptions, + ReadTreeResponse, + UrlReader, + UrlReaderPredicateTuple, +} from './types'; type Options = { // UrlReader to fall back to if no other reader is matched @@ -53,6 +58,22 @@ export class UrlReaderPredicateMux implements UrlReader { throw new Error(`No reader found that could handle '${url}'`); } + readTree(url: string, options?: ReadTreeOptions): Promise { + const parsed = new URL(url); + + for (const { predicate, reader } of this.readers) { + if (predicate(parsed)) { + return reader.readTree(url, options); + } + } + + if (this.fallback) { + return this.fallback.readTree(url, options); + } + + throw new Error(`No reader found that could handle '${url}'`); + } + toString() { return `predicateMux{readers=${this.readers .map(t => t.reader) diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index e1d99a2c49..2bb5617907 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -23,6 +23,7 @@ import { BitbucketUrlReader } from './BitbucketUrlReader'; import { GithubUrlReader } from './GithubUrlReader'; import { GitlabUrlReader } from './GitlabUrlReader'; import { FetchUrlReader } from './FetchUrlReader'; +import { ReadTreeResponseFactory } from './tree'; type CreateOptions = { /** Root config object */ @@ -49,9 +50,10 @@ export class UrlReaders { fallback, }: CreateOptions): UrlReader { const mux = new UrlReaderPredicateMux({ fallback: fallback }); + const treeResponseFactory = ReadTreeResponseFactory.create({ config }); for (const factory of factories ?? []) { - const tuples = factory({ config, logger: logger }); + const tuples = factory({ config, logger: logger, treeResponseFactory }); for (const tuple of tuples) { mux.register(tuple); diff --git a/packages/backend-common/src/reading/__fixtures__/repo.tar.gz b/packages/backend-common/src/reading/__fixtures__/repo.tar.gz new file mode 100644 index 0000000000..7a8e9902a2 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/repo.tar.gz differ diff --git a/packages/backend-common/src/reading/index.ts b/packages/backend-common/src/reading/index.ts index 8ecc08ebca..8a6c2bf1a3 100644 --- a/packages/backend-common/src/reading/index.ts +++ b/packages/backend-common/src/reading/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export type { UrlReader } from './types'; +export type { UrlReader, ReadTreeResponse } from './types'; export { UrlReaders } from './UrlReaders'; export { AzureUrlReader } from './AzureUrlReader'; export { BitbucketUrlReader } from './BitbucketUrlReader'; diff --git a/packages/backend-common/src/reading/tree/ArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ArchiveResponse.test.ts new file mode 100644 index 0000000000..fd351863b2 --- /dev/null +++ b/packages/backend-common/src/reading/tree/ArchiveResponse.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { resolve as resolvePath } from 'path'; +import { ArchiveResponse } from './ArchiveResponse'; + +const archiveData = fs.readFileSync( + resolvePath(__filename, '../../__fixtures__/repo.tar.gz'), +); + +describe('ArchiveResponse', () => { + beforeEach(() => { + mockFs({ + '/test-archive.tar.gz': archiveData, + '/tmp': mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should read files', async () => { + const stream = fs.createReadStream('/test-archive.tar.gz'); + + const res = new ArchiveResponse(stream, 'mock-repo/', '/tmp'); + const files = await res.files(); + + expect(files).toEqual([ + { + path: 'mkdocs.yml', + content: expect.any(Function), + }, + { + path: 'docs/index.md', + content: expect.any(Function), + }, + ]); + const contents = await Promise.all(files.map(f => f.content())); + expect(contents.map(c => c.toString('utf8').trim())).toEqual([ + 'site_name: Test', + '# Test', + ]); + }); + + it('should read files with filter', async () => { + const stream = fs.createReadStream('/test-archive.tar.gz'); + + const res = new ArchiveResponse(stream, 'mock-repo/', '/tmp', path => + path.endsWith('.yml'), + ); + const files = await res.files(); + + expect(files).toEqual([ + { + path: 'mkdocs.yml', + content: expect.any(Function), + }, + ]); + const content = await files[0].content(); + expect(content.toString('utf8').trim()).toEqual('site_name: Test'); + }); + + it('should read as archive and files', async () => { + const stream = fs.createReadStream('/test-archive.tar.gz'); + + const res = new ArchiveResponse(stream, 'mock-repo/', '/tmp'); + const buffer = await res.archive(); + + await expect(res.archive()).rejects.toThrow( + 'Response has already been read', + ); + + const res2 = new ArchiveResponse(buffer, '', '/tmp'); + const files = await res2.files(); + + expect(files).toEqual([ + { + path: 'mkdocs.yml', + content: expect.any(Function), + }, + { + path: 'docs/index.md', + content: expect.any(Function), + }, + ]); + const contents = await Promise.all(files.map(f => f.content())); + expect(contents.map(c => c.toString('utf8').trim())).toEqual([ + 'site_name: Test', + '# Test', + ]); + }); + + it('should extract entire archive into directory', async () => { + const stream = fs.createReadStream('/test-archive.tar.gz'); + + const res = new ArchiveResponse(stream, '', '/tmp'); + const dir = await res.dir(); + + await expect( + fs.readFile(resolvePath(dir, 'mock-repo/mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(resolvePath(dir, 'mock-repo/docs/index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + + it('should extract archive into directory with a subpath', async () => { + const stream = fs.createReadStream('/test-archive.tar.gz'); + + const res = new ArchiveResponse(stream, 'mock-repo/docs/', '/tmp'); + const dir = await res.dir(); + + expect(dir).toMatch(/^\/tmp\/.*$/); + await expect( + fs.readFile(resolvePath(dir, 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + + it('should extract archive into directory with a subpath and filter', async () => { + const stream = fs.createReadStream('/test-archive.tar.gz'); + + const res = new ArchiveResponse(stream, 'mock-repo/', '/tmp', path => + path.endsWith('.yml'), + ); + const dir = await res.dir({ targetDir: '/tmp' }); + + expect(dir).toBe('/tmp'); + await expect(fs.pathExists(resolvePath(dir, 'mkdocs.yml'))).resolves.toBe( + true, + ); + await expect( + fs.pathExists(resolvePath(dir, 'docs/index.md')), + ).resolves.toBe(false); + }); +}); diff --git a/packages/backend-common/src/reading/tree/ArchiveResponse.ts b/packages/backend-common/src/reading/tree/ArchiveResponse.ts new file mode 100644 index 0000000000..e16be63127 --- /dev/null +++ b/packages/backend-common/src/reading/tree/ArchiveResponse.ts @@ -0,0 +1,160 @@ +/* + * 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 tar, { Parse, ParseStream, ReadEntry } from 'tar'; +import path from 'path'; +import fs from 'fs-extra'; +import { Readable, pipeline as pipelineCb } from 'stream'; +import { promisify } from 'util'; +import concatStream from 'concat-stream'; +import { + ReadTreeResponse, + ReadTreeResponseFile, + ReadTreeResponseDirOptions, +} from '../types'; + +// Tar types for `Parse` is not a proper constructor, but it should be +const TarParseStream = (Parse as unknown) as { new (): ParseStream }; + +const pipeline = promisify(pipelineCb); + +/** + * Wraps a tar archive stream into a tree response reader. + */ +export class ArchiveResponse implements ReadTreeResponse { + private read = false; + + constructor( + private readonly stream: Readable, + private readonly subPath: string, + private readonly workDir: string, + private readonly filter?: (path: string) => boolean, + ) { + if (subPath) { + if (!subPath.endsWith('/')) { + this.subPath += '/'; + } + if (subPath.startsWith('/')) { + throw new TypeError( + `ArchiveResponse subPath must not start with a /, got '${subPath}'`, + ); + } + } + } + + // Make sure the input stream is only read once + private onlyOnce() { + if (this.read) { + throw new Error('Response has already been read'); + } + this.read = true; + } + + async files(): Promise { + this.onlyOnce(); + + const files = Array(); + const parser = new TarParseStream(); + + parser.on('entry', (entry: ReadEntry & Readable) => { + if (entry.type === 'Directory') { + entry.resume(); + return; + } + + if (this.subPath) { + if (!entry.path.startsWith(this.subPath)) { + entry.resume(); + return; + } + } + + const path = entry.path.slice(this.subPath.length); + if (this.filter) { + if (!this.filter(path)) { + entry.resume(); + return; + } + } + + const content = new Promise(async resolve => { + await pipeline(entry, concatStream(resolve)); + }); + + files.push({ path, content: () => content }); + + entry.resume(); + }); + + await pipeline(this.stream, parser); + + return files; + } + + async archive(): Promise { + if (!this.subPath) { + this.onlyOnce(); + + return this.stream; + } + + // TODO(Rugvip): method for repacking a tar with a subpath is to simply extract into a + // tmp dir and recreate the archive. Would be nicer to stream things instead. + const tmpDir = await this.dir(); + + try { + const data = await new Promise(async resolve => { + await pipeline( + tar.create({ cwd: tmpDir }, ['']), + concatStream(resolve), + ); + }); + return Readable.from(data); + } finally { + await fs.remove(tmpDir); + } + } + + async dir(options?: ReadTreeResponseDirOptions): Promise { + this.onlyOnce(); + + const dir = + options?.targetDir ?? + (await fs.mkdtemp(path.join(this.workDir, 'backstage-'))); + + const strip = this.subPath ? this.subPath.split('/').length - 1 : 0; + + await pipeline( + this.stream, + tar.extract({ + strip, + cwd: dir, + filter: path => { + if (this.subPath && !path.startsWith(this.subPath)) { + return false; + } + if (this.filter) { + const innerPath = path.split('/').slice(strip).join('/'); + return this.filter(innerPath); + } + return true; + }, + }), + ); + + return dir; + } +} diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts new file mode 100644 index 0000000000..a134d185f3 --- /dev/null +++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts @@ -0,0 +1,50 @@ +/* + * 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 os from 'os'; +import { Readable } from 'stream'; +import { Config } from '@backstage/config'; +import { ReadTreeResponse } from '../types'; +import { ArchiveResponse } from './ArchiveResponse'; + +type FromArchiveOptions = { + // A binary stream of a tar archive. + stream: Readable; + // If set, the root of the tree will be set to the given directory path. + path?: string; + // Filter passed on from the ReadTreeOptions + filter?: (path: string) => boolean; +}; + +export class ReadTreeResponseFactory { + static create(options: { config: Config }): ReadTreeResponseFactory { + return new ReadTreeResponseFactory( + options.config.getOptionalString('backend.workingDirectory') ?? + os.tmpdir(), + ); + } + + constructor(private readonly workDir: string) {} + + async fromArchive(options: FromArchiveOptions): Promise { + return new ArchiveResponse( + options.stream, + options.path ?? '', + this.workDir, + options.filter, + ); + } +} diff --git a/packages/backend-common/src/reading/tree/index.ts b/packages/backend-common/src/reading/tree/index.ts new file mode 100644 index 0000000000..858cf15877 --- /dev/null +++ b/packages/backend-common/src/reading/tree/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { ReadTreeResponseFactory } from './ReadTreeResponseFactory'; diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index e423db3eca..f9dca3e1d5 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -16,12 +16,30 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { ReadTreeResponseFactory } from './tree'; + +export type ReadTreeOptions = { + /** + * A filter that can be used to select which files should be included. + * + * The path passed to the filter function is the relative path from the URL + * that the file tree is fetched from, without any leading '/'. + * + * For example, given the URL https://github.com/my/repo/tree/master/my-dir, a file + * at https://github.com/my/repo/blob/master/my-dir/my-subdir/my-file.txt will + * be represented as my-subdir/my-file.txt + * + * If no filter is provided all files are extracted. + */ + filter?(path: string): boolean; +}; /** * A generic interface for fetching plain data from URLs. */ export type UrlReader = { read(url: string): Promise; + readTree(url: string, options?: ReadTreeOptions): Promise; }; export type UrlReaderPredicateTuple = { @@ -36,4 +54,21 @@ export type UrlReaderPredicateTuple = { export type ReaderFactory = (options: { config: Config; logger: Logger; + treeResponseFactory: ReadTreeResponseFactory; }) => UrlReaderPredicateTuple[]; + +export type ReadTreeResponseFile = { + path: string; + content(): Promise; +}; + +export type ReadTreeResponseDirOptions = { + /** The directory to write files to. Defaults to the OS tmpdir or `backend.workingDirectory` if set in config */ + targetDir?: string; +}; + +export type ReadTreeResponse = { + files(): Promise; + archive(): Promise; + dir(options?: ReadTreeResponseDirOptions): Promise; +}; diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 5ff7ed4271..710de6f1d7 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; +import { Config } from '@backstage/config'; import compression from 'compression'; import cors from 'cors'; import express, { Router } from 'express'; @@ -77,7 +77,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { this.module = moduleRef; } - loadConfig(config: ConfigReader): ServiceBuilder { + loadConfig(config: Config): ServiceBuilder { const backendConfig = config.getOptionalConfig('backend'); if (!backendConfig) { return this; diff --git a/packages/cli/src/commands/plugin/export.ts b/packages/backend-common/src/service/lib/metrics.test.ts similarity index 50% rename from packages/cli/src/commands/plugin/export.ts rename to packages/backend-common/src/service/lib/metrics.test.ts index 8cbbe5b3ee..9126423b7e 100644 --- a/packages/cli/src/commands/plugin/export.ts +++ b/packages/backend-common/src/service/lib/metrics.test.ts @@ -14,21 +14,24 @@ * limitations under the License. */ -import { Command } from 'commander'; -import { loadConfig } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; -import { paths } from '../../lib/paths'; -import { buildBundle } from '../../lib/bundler'; +import { normalizePath } from './metrics'; -export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: process.env.NODE_ENV ?? 'production', - rootPaths: [paths.targetRoot, paths.targetDir], +describe('normalizePath', () => { + it('should normalize /path to /path', async () => { + const path = normalizePath({ url: 'http://server/path' }); + + expect(path).toBe('/path'); }); - await buildBundle({ - entry: 'dev/index', - statsJsonEnabled: cmd.stats, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, + + it('should normalize /path/test to /path', async () => { + const path = normalizePath({ url: 'http://server/path/test' }); + + expect(path).toBe('/path'); }); -}; + + it('should normalize /api/plugin-name/test to /api/plugin-name', async () => { + const path = normalizePath({ url: 'http://server/api/plugin-name/test' }); + + expect(path).toBe('/api/plugin-name'); + }); +}); diff --git a/packages/backend-common/src/service/lib/metrics.ts b/packages/backend-common/src/service/lib/metrics.ts index d7b544765b..37d441f53c 100644 --- a/packages/backend-common/src/service/lib/metrics.ts +++ b/packages/backend-common/src/service/lib/metrics.ts @@ -16,6 +16,22 @@ import prom from 'prom-client'; import promBundle from 'express-prom-bundle'; import { RequestHandler } from 'express'; +import * as url from 'url'; + +const rootRegEx = new RegExp('^/([^/]*)/.*'); +const apiRegEx = new RegExp('^/api/([^/]*)/.*'); + +export function normalizePath(req: any): string { + const path = url.parse(req.originalUrl || req.url).pathname || '/'; + + // Capture /api/ and the plugin name + if (apiRegEx.test(path)) { + return path.replace(apiRegEx, '/api/$1'); + } + + // Only the first path segment at root level + return path.replace(rootRegEx, '/$1'); +} /** * Adds a /metrics endpoint, register default runtime metrics and instrument the router. @@ -31,7 +47,7 @@ export function metricsHandler(): RequestHandler { // cardinality (e.g. path params). Instead we would have to template them. However, this // is difficult, as every backend plugin might use different routes. Instead we only take // the first directory of the path, to have at least an idea how each plugin performs: - normalizePath: [['^/([^/]*)/.*', '/$1']], + normalizePath, promClient: { collectDefaultMetrics: {} }, }); } diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md new file mode 100644 index 0000000000..a8bc1fef7d --- /dev/null +++ b/packages/backend/CHANGELOG.md @@ -0,0 +1,135 @@ +# example-backend + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [f531d307c] +- Updated dependencies [3efd03c0e] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] +- Updated dependencies [d33f5157c] + - @backstage/backend-common@0.3.0 + - @backstage/plugin-app-backend@0.3.0 + - @backstage/plugin-catalog-backend@0.2.1 + - example-app@0.2.2 + - @backstage/plugin-scaffolder-backend@0.3.1 + - @backstage/plugin-auth-backend@0.2.2 + - @backstage/plugin-graphql-backend@0.1.3 + - @backstage/plugin-kubernetes-backend@0.1.3 + - @backstage/plugin-proxy-backend@0.2.1 + - @backstage/plugin-rollbar-backend@0.1.3 + - @backstage/plugin-sentry-backend@0.1.3 + - @backstage/plugin-techdocs-backend@0.2.1 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [752808090] +- Updated dependencies [462876399] +- Updated dependencies [59166e5ec] +- Updated dependencies [33b7300eb] + - @backstage/plugin-auth-backend@0.2.1 + - @backstage/plugin-scaffolder-backend@0.3.0 + - @backstage/backend-common@0.2.1 + - example-app@0.2.1 + +## 0.2.0 + +### Patch Changes + +- 440a17b39: Bump @backstage/catalog-backend and pass the now required UrlReader interface to the plugin +- 6840a68df: Pass GitHub token into Scaffolder GitHub Preparer +- 8c2b76e45: **BREAKING CHANGE** + + The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed. + Instead, the CLI and backend process now accept one or more `--config` flags to load config files. + + Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded. + If passing any `--config ` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one. + + The old behaviour of for example `APP_ENV=development` can be replicated using the following flags: + + ```bash + --config ../../app-config.yaml --config ../../app-config.development.yaml + ``` + +- 7bbeb049f: Change loadBackendConfig to return the config directly +- Updated dependencies [28edd7d29] +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [3e254503d] +- Updated dependencies [6d29605db] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [5249594c5] +- Updated dependencies [56e4eb589] +- Updated dependencies [b4e5466e1] +- Updated dependencies [6f1768c0f] +- Updated dependencies [e37c0a005] +- Updated dependencies [3472c8be7] +- Updated dependencies [57d555eb2] +- Updated dependencies [61db1ddc6] +- Updated dependencies [81cb94379] +- Updated dependencies [1687b8fbb] +- Updated dependencies [a768a07fb] +- Updated dependencies [a768a07fb] +- Updated dependencies [f00ca3cb8] +- Updated dependencies [0c370c979] +- Updated dependencies [ce1f55398] +- Updated dependencies [e6b00e3af] +- Updated dependencies [9226c2aaa] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [99710b102] +- Updated dependencies [6579769df] +- Updated dependencies [002860e7a] +- Updated dependencies [5adfc005e] +- Updated dependencies [33454c0f2] +- Updated dependencies [183e2a30d] +- Updated dependencies [948052cbb] +- Updated dependencies [65d722455] +- Updated dependencies [b652bf2cc] +- Updated dependencies [4036ff59d] +- Updated dependencies [991a950e0] +- Updated dependencies [512d70973] +- Updated dependencies [8c2b76e45] +- Updated dependencies [8bdf0bcf5] +- Updated dependencies [c926765a2] +- Updated dependencies [5a920c6e4] +- Updated dependencies [2f62e1804] +- Updated dependencies [440a17b39] +- Updated dependencies [fa56f4615] +- Updated dependencies [8afce088a] +- Updated dependencies [4c4eab81b] +- Updated dependencies [22ff8fba5] +- Updated dependencies [36a71d278] +- Updated dependencies [b3d57961c] +- Updated dependencies [6840a68df] +- Updated dependencies [a5cb46bac] +- Updated dependencies [49d70ccab] +- Updated dependencies [1c8c43756] +- Updated dependencies [26e69ab1a] +- Updated dependencies [5e4551e3a] +- Updated dependencies [e142a2767] +- Updated dependencies [e7f5471fd] +- Updated dependencies [e3d063ffa] +- Updated dependencies [440a17b39] +- Updated dependencies [7bbeb049f] + - @backstage/plugin-app-backend@0.2.0 + - @backstage/plugin-auth-backend@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/plugin-scaffolder-backend@0.2.0 + - @backstage/plugin-techdocs-backend@0.2.0 + - @backstage/plugin-catalog-backend@0.2.0 + - @backstage/plugin-proxy-backend@0.2.0 + - @backstage/backend-common@0.2.0 + - example-app@0.2.0 + - @backstage/plugin-graphql-backend@0.1.2 + - @backstage/plugin-kubernetes-backend@0.1.2 + - @backstage/plugin-rollbar-backend@0.1.2 + - @backstage/plugin-sentry-backend@0.1.2 diff --git a/packages/backend/README.md b/packages/backend/README.md index c45a0d28d9..d458268846 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -55,9 +55,9 @@ in `app-config.yaml` under `catalog.locations`. For local development you can ov We chose [Passport](http://www.passportjs.org/) as authentication platform due to its comprehensive set of supported authentication [strategies](http://www.passportjs.org/packages/). -Read more about the [auth-backend](https://github.com/spotify/backstage/blob/master/plugins/auth-backend/README.md) and [how to add a new provider](https://github.com/spotify/backstage/blob/master/docs/auth/add-auth-provider.md) +Read more about the [auth-backend](https://github.com/backstage/backstage/blob/master/plugins/auth-backend/README.md) and [how to add a new provider](https://github.com/backstage/backstage/blob/master/docs/auth/add-auth-provider.md) ## Documentation -- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/backend/package.json b/packages/backend/package.json index c3a912c93e..014a3f78d5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.1.1-alpha.24", + "version": "0.2.2", "main": "dist/index.cjs.js", "types": "src/index.ts", "private": true, @@ -18,34 +18,34 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.24", - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", - "@backstage/plugin-app-backend": "^0.1.1-alpha.24", - "@backstage/plugin-auth-backend": "^0.1.1-alpha.24", - "@backstage/plugin-catalog-backend": "^0.1.1-alpha.24", - "@backstage/plugin-graphql-backend": "^0.1.1-alpha.24", - "@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.24", - "@backstage/plugin-proxy-backend": "^0.1.1-alpha.24", - "@backstage/plugin-rollbar-backend": "^0.1.1-alpha.24", - "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.24", - "@backstage/plugin-sentry-backend": "^0.1.1-alpha.24", - "@backstage/plugin-techdocs-backend": "^0.1.1-alpha.24", - "@gitbeaker/node": "^23.5.0", + "@backstage/backend-common": "^0.3.0", + "@backstage/catalog-model": "^0.2.0", + "@backstage/config": "^0.1.1", + "@backstage/plugin-app-backend": "^0.3.0", + "@backstage/plugin-auth-backend": "^0.2.2", + "@backstage/plugin-catalog-backend": "^0.2.1", + "@backstage/plugin-graphql-backend": "^0.1.3", + "@backstage/plugin-kubernetes-backend": "^0.1.3", + "@backstage/plugin-proxy-backend": "^0.2.1", + "@backstage/plugin-rollbar-backend": "^0.1.3", + "@backstage/plugin-scaffolder-backend": "^0.3.1", + "@backstage/plugin-sentry-backend": "^0.1.3", + "@backstage/plugin-techdocs-backend": "^0.2.1", + "@gitbeaker/node": "^25.2.0", "@octokit/rest": "^18.0.0", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.0", - "example-app": "^0.1.1-alpha.24", + "example-app": "^0.2.2", "express": "^4.17.1", "express-promise-router": "^3.0.3", - "knex": "^0.21.1", + "knex": "^0.21.6", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", "sqlite3": "^5.0.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", "@types/dockerode": "^2.5.32", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 959af71427..b74954ecc5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -24,17 +24,16 @@ import Router from 'express-promise-router'; import { - ensureDatabaseExists, - createDatabaseClient, createServiceBuilder, loadBackendConfig, getRootLogger, useHotMemoize, notFoundHandler, + SingleConnectionDatabaseManager, SingleHostDiscovery, UrlReaders, } from '@backstage/backend-common'; -import { ConfigReader, AppConfig } from '@backstage/config'; +import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; @@ -48,37 +47,28 @@ import graphql from './plugins/graphql'; import app from './plugins/app'; import { PluginEnvironment } from './types'; -function makeCreateEnv(loadedConfigs: AppConfig[]) { - const config = ConfigReader.fromConfigs(loadedConfigs); +function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); root.info(`Created UrlReader ${reader}`); + const databaseManager = SingleConnectionDatabaseManager.fromConfig(config); + return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); - const database = createDatabaseClient( - config.getConfig('backend.database'), - { - connection: { - database: `backstage_plugin_${plugin}`, - }, - }, - ); + const database = databaseManager.forPlugin(plugin); return { logger, database, config, reader, discovery }; }; } async function main() { - const configs = await loadBackendConfig(); - const configReader = ConfigReader.fromConfigs(configs); - const createEnv = makeCreateEnv(configs); - await ensureDatabaseExists( - configReader.getConfig('backend.database'), - 'backstage_plugin_catalog', - 'backstage_plugin_auth', - ); + const config = await loadBackendConfig({ + argv: process.argv, + logger: getRootLogger(), + }); + const createEnv = makeCreateEnv(config); const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); @@ -105,7 +95,7 @@ async function main() { apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) - .loadConfig(configReader) + .loadConfig(config) .addRouter('', await healthcheck(healthcheckEnv)) .addRouter('/api', apiRouter) .addRouter('', await app(appEnv)); diff --git a/packages/backend/src/plugins/app.ts b/packages/backend/src/plugins/app.ts index c9f7c0622a..637af80974 100644 --- a/packages/backend/src/plugins/app.ts +++ b/packages/backend/src/plugins/app.ts @@ -17,9 +17,13 @@ import { createRouter } from '@backstage/plugin-app-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin({ logger }: PluginEnvironment) { +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { return await createRouter({ logger, + config, appPackageName: 'example-app', }); } diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index c1d390f8cc..3c205a3800 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -14,35 +14,21 @@ * limitations under the License. */ +import { useHotCleanup } from '@backstage/backend-common'; import { + CatalogBuilder, createRouter, - DatabaseEntitiesCatalog, - DatabaseLocationsCatalog, - DatabaseManager, - HigherOrderOperations, - LocationReaders, runPeriodically, } from '@backstage/plugin-catalog-backend'; import { PluginEnvironment } from '../types'; -import { useHotCleanup } from '@backstage/backend-common'; -export default async function createPlugin({ - logger, - config, - reader, - database, -}: PluginEnvironment) { - const locationReader = new LocationReaders({ logger, reader, config }); - - const db = await DatabaseManager.createDatabase(database, { logger }); - const entitiesCatalog = new DatabaseEntitiesCatalog(db); - const locationsCatalog = new DatabaseLocationsCatalog(db); - const higherOrderOperation = new HigherOrderOperations( +export default async function createPlugin(env: PluginEnvironment) { + const builder = new CatalogBuilder(env); + const { entitiesCatalog, locationsCatalog, - locationReader, - logger, - ); + higherOrderOperation, + } = await builder.build(); useHotCleanup( module, @@ -53,6 +39,6 @@ export default async function createPlugin({ entitiesCatalog, locationsCatalog, higherOrderOperation, - logger, + logger: env.logger, }); } diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index c5b36ab8da..5d36d508a5 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -17,22 +17,13 @@ import { CookieCutter, createRouter, - FilePreparer, - GithubPreparer, - GitlabPreparer, - AzurePreparer, Preparers, Publishers, - GithubPublisher, - GitlabPublisher, - AzurePublisher, CreateReactAppTemplater, Templaters, - RepoVisibilityOptions, + CatalogEntityClient, } from '@backstage/plugin-scaffolder-backend'; -import { Octokit } from '@octokit/rest'; -import { Gitlab } from '@gitbeaker/node'; -import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; +import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -46,108 +37,21 @@ export default async function createPlugin({ templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); - const filePreparer = new FilePreparer(); - const githubPreparer = new GithubPreparer(); - const gitlabPreparer = new GitlabPreparer(config); - const azurePreparer = new AzurePreparer(config); - const preparers = new Preparers(); - - preparers.register('file', filePreparer); - preparers.register('github', githubPreparer); - preparers.register('gitlab', gitlabPreparer); - preparers.register('gitlab/api', gitlabPreparer); - preparers.register('azure/api', azurePreparer); - - const publishers = new Publishers(); - - const githubConfig = config.getOptionalConfig('scaffolder.github'); - - if (githubConfig) { - try { - const repoVisibility = githubConfig.getString( - 'visibility', - ) as RepoVisibilityOptions; - - const githubToken = githubConfig.getString('token'); - const githubClient = new Octokit({ auth: githubToken }); - const githubPublisher = new GithubPublisher({ - client: githubClient, - token: githubToken, - repoVisibility, - }); - publishers.register('file', githubPublisher); - publishers.register('github', githubPublisher); - } catch (e) { - const providerName = 'github'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } - - const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api'); - if (gitLabConfig) { - try { - const gitLabToken = gitLabConfig.getString('token'); - const gitLabClient = new Gitlab({ - host: gitLabConfig.getOptionalString('baseUrl'), - token: gitLabToken, - }); - const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); - publishers.register('gitlab', gitLabPublisher); - publishers.register('gitlab/api', gitLabPublisher); - } catch (e) { - const providerName = 'gitlab'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } - - const azureConfig = config.getOptionalConfig('scaffolder.azure'); - if (azureConfig) { - try { - const baseUrl = azureConfig.getString('baseUrl'); - const azureToken = azureConfig.getConfig('api').getString('token'); - - const authHandler = getPersonalAccessTokenHandler(azureToken); - const webApi = new WebApi(baseUrl, authHandler); - const azureClient = await webApi.getGitApi(); - - const azurePublisher = new AzurePublisher(azureClient, azureToken); - publishers.register('azure/api', azurePublisher); - } catch (e) { - const providerName = 'azure'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } + const preparers = await Preparers.fromConfig(config, { logger }); + const publishers = await Publishers.fromConfig(config, { logger }); const dockerClient = new Docker(); + + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + return await createRouter({ preparers, templaters, publishers, logger, + config, dockerClient, + entityClient, }); } diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index e04d2a2732..de48280e64 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -21,8 +21,8 @@ import { Generators, LocalPublish, TechdocsGenerator, - GithubPreparer, - GitlabPreparer, + CommonGitPreparer, + UrlPreparer, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -31,20 +31,26 @@ export default async function createPlugin({ logger, config, discovery, + reader, }: PluginEnvironment) { const generators = new Generators(); const techdocsGenerator = new TechdocsGenerator(logger, config); generators.register('techdocs', techdocsGenerator); const preparers = new Preparers(); - const githubPreparer = new GithubPreparer(logger); - const gitlabPreparer = new GitlabPreparer(logger); + const directoryPreparer = new DirectoryPreparer(logger); preparers.register('dir', directoryPreparer); - preparers.register('github', githubPreparer); - preparers.register('gitlab', gitlabPreparer); - const publisher = new LocalPublish(logger); + const commonGitPreparer = new CommonGitPreparer(logger); + preparers.register('github', commonGitPreparer); + preparers.register('gitlab', commonGitPreparer); + preparers.register('azure/api', commonGitPreparer); + + const urlPreparer = new UrlPreparer(reader, logger); + preparers.register('url', urlPreparer); + + const publisher = new LocalPublish(logger, discovery); const dockerClient = new Docker(); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 9257fcc9cf..f63b9dd780 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -14,14 +14,17 @@ * limitations under the License. */ -import Knex from 'knex'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; -import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common'; +import { + PluginDatabaseManager, + PluginEndpointDiscovery, + UrlReader, +} from '@backstage/backend-common'; export type PluginEnvironment = { logger: Logger; - database: Knex; + database: PluginDatabaseManager; config: Config; reader: UrlReader; discovery: PluginEndpointDiscovery; diff --git a/packages/catalog-client/.eslintrc.js b/packages/catalog-client/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/catalog-client/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md new file mode 100644 index 0000000000..574cd301bf --- /dev/null +++ b/packages/catalog-client/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/catalog-client + +## 0.3.0 + +### Minor Changes + +- 717e43de1: Changed the getEntities interface to (1) nest parameters in an object, (2) support field selection, and (3) return an object with an items field for future extension diff --git a/packages/catalog-client/README.md b/packages/catalog-client/README.md new file mode 100644 index 0000000000..574e23cf62 --- /dev/null +++ b/packages/catalog-client/README.md @@ -0,0 +1,17 @@ +# Catalog Client + +Contains a frontend and backend compatible client for communicating with the +Backstage Catalog. + +Backend code may import and use this package directly. + +However, frontend code will not want to import this package directly - use the +`@backstage/plugin-catalog` package instead, which re-exports all of the types +and classes from this package. Thereby, you will also gain access to its +`catalogApiRef`. + +## Links + +- [Default frontend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog) +- [Default backend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json new file mode 100644 index 0000000000..2482f9579d --- /dev/null +++ b/packages/catalog-client/package.json @@ -0,0 +1,35 @@ +{ + "name": "@backstage/catalog-client", + "version": "0.3.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.2.0", + "@backstage/config": "^0.1.1", + "cross-fetch": "^3.0.6" + }, + "devDependencies": { + "@backstage/cli": "^0.3.0", + "@types/jest": "^26.0.7", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/catalog/src/api/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts similarity index 61% rename from plugins/catalog/src/api/CatalogClient.test.ts rename to packages/catalog-client/src/CatalogClient.test.ts index 832a114276..6369f95b76 100644 --- a/plugins/catalog/src/api/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -14,29 +14,33 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { CatalogClient } from './CatalogClient'; -import { Entity } from '@backstage/catalog-model'; -import { UrlPatternDiscovery } from '@backstage/core'; +import { CatalogListResponse, DiscoveryApi } from './types'; const server = setupServer(); const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; -const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); +const discoveryApi: DiscoveryApi = { + async getBaseUrl(_pluginId) { + return mockBaseUrl; + }, +}; describe('CatalogClient', () => { - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + let client: CatalogClient; - let client = new CatalogClient({ discoveryApi }); + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); beforeEach(() => { client = new CatalogClient({ discoveryApi }); }); - describe('getEntiies', () => { - const defaultResponse: Entity[] = [ + describe('getEntities', () => { + const defaultServiceResponse: Entity[] = [ { apiVersion: '1', kind: 'Component', @@ -54,38 +58,59 @@ describe('CatalogClient', () => { }, }, ]; + const defaultResponse: CatalogListResponse = { + items: defaultServiceResponse, + }; beforeEach(() => { server.use( rest.get(`${mockBaseUrl}/entities`, (_, res, ctx) => { - return res(ctx.json(defaultResponse)); + return res(ctx.json(defaultServiceResponse)); }), ); }); it('should entities from correct endpoint', async () => { - const entities = await client.getEntities(); - expect(entities).toEqual(defaultResponse); + const response = await client.getEntities(); + expect(response).toEqual(defaultResponse); }); it('builds entity search filters properly', async () => { expect.assertions(2); + server.use( rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe( - 'a=1&b=2&b=3&%C3%B6=%3D', - ); + expect(req.url.search).toBe('?filter=a=1,b=2,b=3,%C3%B6=%3D'); return res(ctx.json([])); }), ); - const entities = await client.getEntities({ - a: '1', - b: ['2', '3'], - ö: '=', + const response = await client.getEntities({ + filter: { + a: '1', + b: ['2', '3'], + ö: '=', + }, }); - expect(entities).toEqual([]); + expect(response.items).toEqual([]); + }); + + it('builds entity field selectors properly', async () => { + expect.assertions(2); + + server.use( + rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => { + expect(req.url.search).toBe('?fields=a.b,%C3%B6'); + return res(ctx.json([])); + }), + ); + + const response = await client.getEntities({ + fields: ['a.b', 'ö'], + }); + + expect(response.items).toEqual([]); }); }); }); diff --git a/plugins/catalog/src/api/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts similarity index 70% rename from plugins/catalog/src/api/CatalogClient.ts rename to packages/catalog-client/src/CatalogClient.ts index d5ff033caa..362b1e71da 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -16,11 +16,19 @@ import { Entity, + EntityName, Location, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import { CatalogApi, EntityCompoundName } from './types'; -import { DiscoveryApi } from '@backstage/core'; +import fetch from 'cross-fetch'; +import { + AddLocationRequest, + AddLocationResponse, + CatalogApi, + CatalogEntitiesRequest, + CatalogListResponse, + DiscoveryApi, +} from './types'; export class CatalogClient implements CatalogApi { private readonly discoveryApi: DiscoveryApi; @@ -29,6 +37,107 @@ export class CatalogClient implements CatalogApi { this.discoveryApi = options.discoveryApi; } + async getLocationById(id: String): Promise { + return await this.getOptional(`/locations/${id}`); + } + + async getEntities( + request?: CatalogEntitiesRequest, + ): Promise> { + const { filter = {}, fields = [] } = request ?? {}; + const params: string[] = []; + + const filterParts: string[] = []; + for (const [key, value] of Object.entries(filter)) { + for (const v of [value].flat()) { + filterParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`); + } + } + if (filterParts.length) { + params.push(`filter=${filterParts.join(',')}`); + } + + if (fields.length) { + params.push(`fields=${fields.map(encodeURIComponent).join(',')}`); + } + + const query = params.length ? `?${params.join('&')}` : ''; + const entities: Entity[] = await this.getRequired(`/entities${query}`); + return { items: entities }; + } + + async getEntityByName(compoundName: EntityName): Promise { + const { kind, namespace = 'default', name } = compoundName; + return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`); + } + + async addLocation({ + type = 'url', + target, + dryRun, + }: AddLocationRequest): Promise { + const response = await fetch( + `${await this.discoveryApi.getBaseUrl('catalog')}/locations${ + dryRun ? '?dryRun=true' : '' + }`, + { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({ type, target }), + }, + ); + + if (response.status !== 201) { + throw new Error(await response.text()); + } + + const { location, entities } = await response.json(); + + if (!location) { + throw new Error(`Location wasn't added: ${target}`); + } + + if (entities.length === 0) { + throw new Error( + `Location was added but has no entities specified yet: ${target}`, + ); + } + return { + location, + entities, + }; + } + + async getLocationByEntity(entity: Entity): Promise { + const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION]; + const all: { data: Location }[] = await this.getRequired('/locations'); + return all + .map(r => r.data) + .find(l => locationCompound === `${l.type}:${l.target}`); + } + + async removeEntityByUid(uid: string): Promise { + const response = await fetch( + `${await this.discoveryApi.getBaseUrl('catalog')}/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; + } + + // + // Private methods + // + private async getRequired(path: string): Promise { const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`; const response = await fetch(url); @@ -58,87 +167,4 @@ export class CatalogClient implements CatalogApi { return await response.json(); } - - async getLocationById(id: String): Promise { - return await this.getOptional(`/locations/${id}`); - } - - async getEntities( - filter?: Record, - ): Promise { - let path = `/entities`; - if (filter) { - 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); - } - - async getEntityByName( - compoundName: EntityCompoundName, - ): Promise { - const { kind, namespace = 'default', name } = compoundName; - return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`); - } - - async addLocation(type: string, target: string) { - const response = await fetch( - `${await this.discoveryApi.getBaseUrl('catalog')}/locations`, - { - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - body: JSON.stringify({ type, target }), - }, - ); - - if (response.status !== 201) { - throw new Error(await response.text()); - } - - const { location, entities } = await response.json(); - - if (!location || entities.length === 0) - throw new Error(`Location wasn't added: ${target}`); - - return { - location, - entities, - }; - } - - async getLocationByEntity(entity: Entity): Promise { - const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION]; - const all: { data: Location }[] = await this.getRequired('/locations'); - return all - .map(r => r.data) - .find(l => locationCompound === `${l.type}:${l.target}`); - } - - async removeEntityByUid(uid: string): Promise { - const response = await fetch( - `${await this.discoveryApi.getBaseUrl('catalog')}/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/packages/catalog-client/src/index.ts b/packages/catalog-client/src/index.ts new file mode 100644 index 0000000000..9bd13f9b7c --- /dev/null +++ b/packages/catalog-client/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { CatalogClient } from './CatalogClient'; +export type { CatalogApi } from './types'; diff --git a/packages/catalog-client/src/setupTests.ts b/packages/catalog-client/src/setupTests.ts new file mode 100644 index 0000000000..ba33cf996b --- /dev/null +++ b/packages/catalog-client/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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 {}; diff --git a/plugins/catalog/src/api/types.ts b/packages/catalog-client/src/types.ts similarity index 55% rename from plugins/catalog/src/api/types.ts rename to packages/catalog-client/src/types.ts index 36c86f2e96..e72317d444 100644 --- a/plugins/catalog/src/api/types.ts +++ b/packages/catalog-client/src/types.ts @@ -14,33 +14,42 @@ * limitations under the License. */ -import { createApiRef } from '@backstage/core'; -import { Entity, Location } from '@backstage/catalog-model'; +import { Entity, EntityName, Location } from '@backstage/catalog-model'; -export const catalogApiRef = createApiRef({ - id: 'plugin.catalog.service', - description: - 'Used by the Catalog plugin to make requests to accompanying backend', -}); +export type CatalogEntitiesRequest = { + filter?: Record | undefined; + fields?: string[] | undefined; +}; -export type EntityCompoundName = { - kind: string; - namespace?: string; - name: string; +export type CatalogListResponse = { + items: T[]; }; export interface CatalogApi { getLocationById(id: String): Promise; - getEntityByName( - compoundName: EntityCompoundName, - ): Promise; - getEntities(filter?: Record): Promise; - addLocation(type: string, target: string): Promise; + getEntityByName(name: EntityName): Promise; + getEntities( + request?: CatalogEntitiesRequest, + ): Promise>; + addLocation(location: AddLocationRequest): Promise; getLocationByEntity(entity: Entity): Promise; removeEntityByUid(uid: string): Promise; } +export type AddLocationRequest = { + type?: string; + target: string; + dryRun?: boolean; +}; + export type AddLocationResponse = { location: Location; entities: Entity[]; }; + +/** + * This is a copy of the core DiscoveryApi, to avoid importing core. + */ +export type DiscoveryApi = { + getBaseUrl(pluginId: string): Promise; +}; diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md new file mode 100644 index 0000000000..051aa82c80 --- /dev/null +++ b/packages/catalog-model/CHANGELOG.md @@ -0,0 +1,28 @@ +# @backstage/catalog-model + +## 0.2.0 + +### Minor Changes + +- 3a4236570: Add handling and docs for entity references +- e0be86b6f: Entirely case insensitive read path of entities +- f70a52868: Add the User & Group entities + + A user describes a person, such as an employee, a contractor, or similar. Users belong to Group entities in the catalog. + + A group describes an organizational entity, such as for example a team, a business unit, or a loose collection of people in an interest group. Members of these groups are modeled in the catalog as kind User. + +- 12b5fe940: Add ApiDefinitionAtLocationProcessor that allows to load a API definition from another location +- a768a07fb: Add the ability to import users from GitHub Organization into the catalog. +- 5adfc005e: Changes the various kind policies into a new type `KindValidator`. + + Adds `CatalogProcessor#validateEntityKind` that makes use of the above + validators. This moves entity schema validity checking away from entity + policies and into processors, centralizing the extension points into the + processor chain. + +- b3d57961c: Enable adding locations for config files that does not yet exist by adding a flag to api request + +### Patch Changes + +- fa56f4615: Fix documentation and validation message for tags diff --git a/packages/catalog-model/README.md b/packages/catalog-model/README.md index 6dab6e7cae..8218508be9 100644 --- a/packages/catalog-model/README.md +++ b/packages/catalog-model/README.md @@ -7,6 +7,6 @@ as well as by others that want to consume catalog data. ## Links -- [Default frontend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog) -- [Default backend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend) +- [Default frontend part of the catalog](https://github.com/backstage/backstage/tree/master/plugins/catalog) +- [Default backend part of the catalog](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend) - [The Backstage homepage](https://backstage.io) diff --git a/packages/catalog-model/examples/acme-corp.yaml b/packages/catalog-model/examples/acme-corp.yaml new file mode 100644 index 0000000000..e8047fc143 --- /dev/null +++ b/packages/catalog-model/examples/acme-corp.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: acme-corp + description: A collection of all Backstage example Groups +spec: + type: github + targets: + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/org.yaml diff --git a/packages/catalog-model/examples/acme/backstage-group.yaml b/packages/catalog-model/examples/acme/backstage-group.yaml new file mode 100644 index 0000000000..f992d155bc --- /dev/null +++ b/packages/catalog-model/examples/acme/backstage-group.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: backstage + description: The backstage sub-department +spec: + type: sub-department + parent: infrastructure + ancestors: [infrastructure, acme-corp] + children: [team-a, team-b] + descendants: [team-a, team-b] diff --git a/packages/catalog-model/examples/acme/boxoffice-group.yaml b/packages/catalog-model/examples/acme/boxoffice-group.yaml new file mode 100644 index 0000000000..0be1fe58cb --- /dev/null +++ b/packages/catalog-model/examples/acme/boxoffice-group.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: boxoffice + description: The boxoffice sub-department +spec: + type: sub-department + parent: infrastructure + ancestors: [infrastructure, acme-corp] + children: [team-c, team-d] + descendants: [team-c, team-d] diff --git a/packages/catalog-model/examples/acme/infrastructure-group.yaml b/packages/catalog-model/examples/acme/infrastructure-group.yaml new file mode 100644 index 0000000000..2341782944 --- /dev/null +++ b/packages/catalog-model/examples/acme/infrastructure-group.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: infrastructure + description: The infra department +spec: + type: department + parent: acme-corp + ancestors: [acme-corp] + children: [backstage, boxoffice] + descendants: [backstage, boxoffice, team-a, team-b, team-c, team-d] diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml new file mode 100644 index 0000000000..8c562aaf89 --- /dev/null +++ b/packages/catalog-model/examples/acme/org.yaml @@ -0,0 +1,27 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: acme-corp + description: The acme-corp organization +spec: + type: organization + ancestors: [] + children: [infrastructure] + descendants: + [infrastructure, backstage, boxoffice, team-a, team-b, team-c, team-d] +--- +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-groups + description: A collection of all Backstage example Groups +spec: + type: github + targets: + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/infrastructure-group.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/boxoffice-group.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/backstage-group.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-a-group.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-b-group.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-c-group.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/team-d-group.yaml diff --git a/packages/catalog-model/examples/acme/team-a-group.yaml b/packages/catalog-model/examples/acme/team-a-group.yaml new file mode 100644 index 0000000000..dacc4c6c3c --- /dev/null +++ b/packages/catalog-model/examples/acme/team-a-group.yaml @@ -0,0 +1,44 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: team-a + description: Team A +spec: + type: team + parent: backstage + ancestors: [backstage, infrastructure, acme-corp] + children: [] + descendants: [] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: breanna.davison +spec: + profile: + displayName: Breanna Davison + email: breanna-davison@example.com + picture: https://example.com/staff/breanna.jpeg + memberOf: [team-a] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: janelle.dawe +spec: + profile: + displayName: Janelle Dawe + email: janelle-dawe@example.com + picture: https://example.com/staff/janelle.jpeg + memberOf: [team-a] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: nigel.manning +spec: + profile: + displayName: Nigel Manning + email: nigel-manning@example.com + picture: https://example.com/staff/nigel.jpeg + memberOf: [team-a] diff --git a/packages/catalog-model/examples/acme/team-b-group.yaml b/packages/catalog-model/examples/acme/team-b-group.yaml new file mode 100644 index 0000000000..00e9e41d80 --- /dev/null +++ b/packages/catalog-model/examples/acme/team-b-group.yaml @@ -0,0 +1,66 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: team-b + description: Team B +spec: + type: team + parent: backstage + ancestors: [backstage, infrastructure, acme-corp] + children: [] + descendants: [] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: amelia.park +spec: + profile: + displayName: Amelia Park + email: amelia-park@example.com + picture: https://example.com/staff/amelia.jpeg + memberOf: [team-b] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: colette.brock +spec: + profile: + displayName: Colette Brock + email: colette-brock@example.com + picture: https://example.com/staff/colette.jpeg + memberOf: [team-b] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: jenny.doe +spec: + profile: + displayName: Jenny Doe + email: jenny-doe@example.com + picture: https://example.com/staff/jenny.jpeg + memberOf: [team-b] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: jonathon.page +spec: + profile: + displayName: Jonathon Page + email: jonathon-page@example.com + picture: https://example.com/staff/jonathon.jpeg + memberOf: [team-b] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: justine.barrow +spec: + profile: + displayName: Justine Barrow + email: justine-barrow@example.com + picture: https://example.com/staff/justine.jpeg + memberOf: [team-b] diff --git a/packages/catalog-model/examples/acme/team-c-group.yaml b/packages/catalog-model/examples/acme/team-c-group.yaml new file mode 100644 index 0000000000..0f97f69cb3 --- /dev/null +++ b/packages/catalog-model/examples/acme/team-c-group.yaml @@ -0,0 +1,66 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: team-c + description: Team C +spec: + type: team + parent: boxoffice + ancestors: [boxoffice, infrastructure, acme-corp] + children: [] + descendants: [] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: calum.leavy +spec: + profile: + displayName: Calum Leavy + email: calum-leavy@example.com + picture: https://example.com/staff/calum.jpeg + memberOf: [team-c] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: frank.tiernan +spec: + profile: + displayName: Frank Tiernan + email: frank-tiernan@example.com + picture: https://example.com/staff/frank.jpeg + memberOf: [team-c] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: peadar.macmahon +spec: + profile: + displayName: Peadar MacMahon + email: peadar-macmahon@example.com + picture: https://example.com/staff/peadar.jpeg + memberOf: [team-c] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: sarah.gilroy +spec: + profile: + displayName: Sarah Gilroy + email: sarah-gilroy@example.com + picture: https://example.com/staff/sarah.jpeg + memberOf: [team-c] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: tara.macgovern +spec: + profile: + displayName: Tara MacGovern + email: tara-macgovern@example.com + picture: https://example.com/staff/tara.jpeg + memberOf: [team-c] diff --git a/packages/catalog-model/examples/acme/team-d-group.yaml b/packages/catalog-model/examples/acme/team-d-group.yaml new file mode 100644 index 0000000000..5a1a53a2e6 --- /dev/null +++ b/packages/catalog-model/examples/acme/team-d-group.yaml @@ -0,0 +1,33 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: team-d + description: Team D +spec: + type: team + parent: boxoffice + ancestors: [boxoffice, infrastructure, acme-corp] + children: [] + descendants: [] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: eva.macdowell +spec: + profile: + displayName: Eva MacDowell + email: eva-macdowell@example.com + picture: https://example.com/staff/eva.jpeg + memberOf: [team-d] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: lucy.sheehan +spec: + profile: + displayName: Lucy Sheehan + email: lucy-sheehan@example.com + picture: https://example.com/staff/lucy.jpeg + memberOf: [team-d] diff --git a/packages/catalog-model/examples/all-apis.yaml b/packages/catalog-model/examples/all-apis.yaml index 3c4f7804bf..0278fc3078 100644 --- a/packages/catalog-model/examples/all-apis.yaml +++ b/packages/catalog-model/examples/all-apis.yaml @@ -6,7 +6,8 @@ metadata: spec: type: github targets: - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/hello-world-api.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/streetlights-api.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/spotify-api.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/swapi-graphql.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/hello-world-api.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/petstore-api.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/spotify-api.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/streetlights-api.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/apis/swapi-graphql.yaml diff --git a/packages/catalog-model/examples/all-components.yaml b/packages/catalog-model/examples/all-components.yaml index cad4eee044..06c44b59d7 100644 --- a/packages/catalog-model/examples/all-components.yaml +++ b/packages/catalog-model/examples/all-components.yaml @@ -6,11 +6,12 @@ metadata: spec: type: github targets: - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/podcast-api-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/queue-proxy-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/searcher-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-lib-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/www-artist-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/shuffle-api-component.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/petstore-component.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml + - https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml diff --git a/packages/catalog-model/examples/hello-world-api.yaml b/packages/catalog-model/examples/apis/hello-world-api.yaml similarity index 100% rename from packages/catalog-model/examples/hello-world-api.yaml rename to packages/catalog-model/examples/apis/hello-world-api.yaml diff --git a/packages/catalog-model/examples/petstore-api.yaml b/packages/catalog-model/examples/apis/petstore-api.yaml similarity index 100% rename from packages/catalog-model/examples/petstore-api.yaml rename to packages/catalog-model/examples/apis/petstore-api.yaml diff --git a/packages/catalog-model/examples/spotify-api.yaml b/packages/catalog-model/examples/apis/spotify-api.yaml similarity index 64% rename from packages/catalog-model/examples/spotify-api.yaml rename to packages/catalog-model/examples/apis/spotify-api.yaml index 30524dfdd4..14deb17abe 100644 --- a/packages/catalog-model/examples/spotify-api.yaml +++ b/packages/catalog-model/examples/apis/spotify-api.yaml @@ -7,8 +7,11 @@ metadata: - spotify - rest annotations: + # The annotation is deprecated, we use placeholders (see below) instead, remove it later. backstage.io/definition-at-location: 'url:https://raw.githubusercontent.com/APIs-guru/openapi-directory/master/APIs/spotify.com/v1/swagger.yaml' spec: type: openapi lifecycle: production owner: spotify@example.com + definition: + $text: https://github.com/APIs-guru/openapi-directory/blob/master/APIs/spotify.com/v1/swagger.yaml diff --git a/packages/catalog-model/examples/streetlights-api.yaml b/packages/catalog-model/examples/apis/streetlights-api.yaml similarity index 100% rename from packages/catalog-model/examples/streetlights-api.yaml rename to packages/catalog-model/examples/apis/streetlights-api.yaml diff --git a/packages/catalog-model/examples/swapi-graphql.yaml b/packages/catalog-model/examples/apis/swapi-graphql.yaml similarity index 99% rename from packages/catalog-model/examples/swapi-graphql.yaml rename to packages/catalog-model/examples/apis/swapi-graphql.yaml index 152d9c0afa..bc5e8e60ac 100644 --- a/packages/catalog-model/examples/swapi-graphql.yaml +++ b/packages/catalog-model/examples/apis/swapi-graphql.yaml @@ -5,6 +5,8 @@ metadata: description: SWAPI GraphQL Schema spec: type: graphql + lifecycle: production + owner: yoda@dagobah.space definition: | schema { query: Root diff --git a/packages/catalog-model/examples/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml similarity index 100% rename from packages/catalog-model/examples/artist-lookup-component.yaml rename to packages/catalog-model/examples/components/artist-lookup-component.yaml diff --git a/packages/catalog-model/examples/petstore-component.yaml b/packages/catalog-model/examples/components/petstore-component.yaml similarity index 100% rename from packages/catalog-model/examples/petstore-component.yaml rename to packages/catalog-model/examples/components/petstore-component.yaml diff --git a/packages/catalog-model/examples/playback-lib-component.yaml b/packages/catalog-model/examples/components/playback-lib-component.yaml similarity index 100% rename from packages/catalog-model/examples/playback-lib-component.yaml rename to packages/catalog-model/examples/components/playback-lib-component.yaml diff --git a/packages/catalog-model/examples/playback-order-component.yaml b/packages/catalog-model/examples/components/playback-order-component.yaml similarity index 100% rename from packages/catalog-model/examples/playback-order-component.yaml rename to packages/catalog-model/examples/components/playback-order-component.yaml diff --git a/packages/catalog-model/examples/podcast-api-component.yaml b/packages/catalog-model/examples/components/podcast-api-component.yaml similarity index 100% rename from packages/catalog-model/examples/podcast-api-component.yaml rename to packages/catalog-model/examples/components/podcast-api-component.yaml diff --git a/packages/catalog-model/examples/queue-proxy-component.yaml b/packages/catalog-model/examples/components/queue-proxy-component.yaml similarity index 100% rename from packages/catalog-model/examples/queue-proxy-component.yaml rename to packages/catalog-model/examples/components/queue-proxy-component.yaml diff --git a/packages/catalog-model/examples/searcher-component.yaml b/packages/catalog-model/examples/components/searcher-component.yaml similarity index 100% rename from packages/catalog-model/examples/searcher-component.yaml rename to packages/catalog-model/examples/components/searcher-component.yaml diff --git a/packages/catalog-model/examples/shuffle-api-component.yaml b/packages/catalog-model/examples/components/shuffle-api-component.yaml similarity index 100% rename from packages/catalog-model/examples/shuffle-api-component.yaml rename to packages/catalog-model/examples/components/shuffle-api-component.yaml diff --git a/packages/catalog-model/examples/www-artist-component.yaml b/packages/catalog-model/examples/components/www-artist-component.yaml similarity index 100% rename from packages/catalog-model/examples/www-artist-component.yaml rename to packages/catalog-model/examples/components/www-artist-component.yaml diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 6a543f7f84..e712e66f58 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.24", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,16 +20,16 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1", "@types/json-schema": "^7.0.5", - "@types/yup": "^0.28.2", + "@types/yup": "^0.29.8", "json-schema": "^0.2.5", "lodash": "^4.17.15", "uuid": "^8.0.0", - "yup": "^0.29.1" + "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/EntityPolicies.test.ts b/packages/catalog-model/src/EntityPolicies.test.ts new file mode 100644 index 0000000000..cd869f798c --- /dev/null +++ b/packages/catalog-model/src/EntityPolicies.test.ts @@ -0,0 +1,110 @@ +import { Entity } from './entity'; +/* + * 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 { EntityPolicies } from './EntityPolicies'; +import { EntityPolicy } from './types'; + +describe('EntityPolicies', () => { + const p1: jest.Mocked = { enforce: jest.fn() }; + const p2: jest.Mocked = { enforce: jest.fn() }; + const entity1: Entity = { + apiVersion: 'a1', + kind: 'k1', + metadata: { name: 'n1' }, + }; + const entity2: Entity = { + apiVersion: 'a2', + kind: 'k2', + metadata: { name: 'n2' }, + }; + + afterEach(() => jest.resetAllMocks()); + + describe('allOf', () => { + it('resolves when no policies', async () => { + const policy = EntityPolicies.allOf([]); + await expect(policy.enforce(entity1)).resolves.toBe(entity1); + }); + + it('resolves when all resolve', async () => { + p1.enforce.mockResolvedValue(entity1); + p2.enforce.mockResolvedValue(entity1); + const policy = EntityPolicies.allOf([p1, p2]); + await expect(policy.enforce(entity1)).resolves.toBe(entity1); + }); + + it('rejects when any rejects', async () => { + p1.enforce.mockResolvedValue(entity1); + p2.enforce.mockRejectedValue(new Error('a')); + const policy = EntityPolicies.allOf([p1, p2]); + await expect(policy.enforce(entity1)).rejects.toThrow('a'); + }); + + it('rejects when any ignores', async () => { + p1.enforce.mockResolvedValue(entity1); + p2.enforce.mockResolvedValue(undefined); + const policy = EntityPolicies.allOf([p1, p2]); + await expect(policy.enforce(entity1)).rejects.toThrow( + /did not return a result/, + ); + }); + + it('passes through transforms properly', async () => { + p1.enforce.mockResolvedValue(entity2); + p2.enforce.mockResolvedValue(entity2); + const policy = EntityPolicies.allOf([p1, p2]); + await expect(policy.enforce(entity1)).resolves.toBe(entity2); + expect(p1.enforce).toBeCalledWith(entity1); + expect(p2.enforce).toBeCalledWith(entity2); + }); + }); + + describe('oneOf', () => { + it('rejects when no policies', async () => { + const policy = EntityPolicies.oneOf([]); + await expect(policy.enforce(entity1)).rejects.toThrow(/did not match/); + }); + + it('resolves when one resolves', async () => { + p1.enforce.mockResolvedValue(undefined); + p2.enforce.mockResolvedValue(entity1); + const policy = EntityPolicies.oneOf([p1, p2]); + await expect(policy.enforce(entity1)).resolves.toBe(entity1); + }); + + it('rejects when one rejects first', async () => { + p1.enforce.mockRejectedValue(new Error('a')); + p2.enforce.mockResolvedValue(entity1); + const policy = EntityPolicies.oneOf([p1, p2]); + await expect(policy.enforce(entity1)).rejects.toThrow('a'); + }); + + it('resolves first resolution when several resolve', async () => { + p1.enforce.mockResolvedValue(entity1); + p2.enforce.mockResolvedValue(entity2); + const policy = EntityPolicies.oneOf([p1, p2]); + await expect(policy.enforce(entity1)).resolves.toBe(entity1); + }); + + it('rejects when all ignore', async () => { + p1.enforce.mockResolvedValue(undefined); + p2.enforce.mockResolvedValue(undefined); + const policy = EntityPolicies.oneOf([p1, p2]); + await expect(policy.enforce(entity1)).rejects.toThrow(/did not match/); + }); + }); +}); diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts index ca59338c16..1f304f8f27 100644 --- a/packages/catalog-model/src/EntityPolicies.ts +++ b/packages/catalog-model/src/EntityPolicies.ts @@ -14,22 +14,7 @@ * limitations under the License. */ -import { - DefaultNamespaceEntityPolicy, - Entity, - FieldFormatEntityPolicy, - NoForeignRootFieldsEntityPolicy, - ReservedFieldsEntityPolicy, - SchemaValidEntityPolicy, -} from './entity'; -import { - apiEntityV1alpha1Policy, - componentEntityV1alpha1Policy, - groupEntityV1alpha1Policy, - locationEntityV1alpha1Policy, - templateEntityV1alpha1Policy, - userEntityV1alpha1Policy, -} from './kinds'; +import { Entity } from './entity'; import { EntityPolicy } from './types'; // Helper that requires that all of a set of policies can be successfully @@ -40,10 +25,13 @@ class AllEntityPolicies implements EntityPolicy { async enforce(entity: Entity): Promise { let result = entity; for (const policy of this.policies) { - const output = await policy.enforce(entity); - if (output) { - result = output; + const output = await policy.enforce(result); + if (!output) { + throw new Error( + `Policy ${policy.constructor.name} did not return a result`, + ); } + result = output; } return result; } @@ -54,10 +42,10 @@ class AllEntityPolicies implements EntityPolicy { class AnyEntityPolicy implements EntityPolicy { constructor(private readonly policies: EntityPolicy[]) {} - async enforce(entity: Entity): Promise { + async enforce(entity: Entity): Promise { for (const policy of this.policies) { const output = await policy.enforce(entity); - if (output !== null) { + if (output) { return output; } } @@ -65,42 +53,11 @@ class AnyEntityPolicy implements EntityPolicy { } } -export class EntityPolicies implements EntityPolicy { - private readonly policy: EntityPolicy; - - static defaultPolicies(): EntityPolicy { - return EntityPolicies.allOf([ - EntityPolicies.allOf([ - new SchemaValidEntityPolicy(), - new DefaultNamespaceEntityPolicy(), - new NoForeignRootFieldsEntityPolicy(), - new FieldFormatEntityPolicy(), - new ReservedFieldsEntityPolicy(), - ]), - EntityPolicies.anyOf([ - componentEntityV1alpha1Policy, - groupEntityV1alpha1Policy, - userEntityV1alpha1Policy, - locationEntityV1alpha1Policy, - templateEntityV1alpha1Policy, - apiEntityV1alpha1Policy, - ]), - ]); - } - - static allOf(policies: EntityPolicy[]): EntityPolicy { +export const EntityPolicies = { + allOf(policies: EntityPolicy[]) { return new AllEntityPolicies(policies); - } - - static anyOf(policies: EntityPolicy[]): EntityPolicy { + }, + oneOf(policies: EntityPolicy[]) { return new AnyEntityPolicy(policies); - } - - constructor(policy: EntityPolicy = EntityPolicies.defaultPolicies()) { - this.policy = policy; - } - - enforce(entity: Entity): Promise { - return this.policy.enforce(entity); - } -} + }, +}; diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index 9e0aec7de6..ac43137546 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -15,6 +15,7 @@ */ import { JsonObject } from '@backstage/config'; +import { EntityName } from '../types'; /** * The format envelope that's common to all versions/kinds of entity. @@ -42,6 +43,11 @@ export type Entity = { * The specification data describing the entity itself. */ spec?: JsonObject; + + /** + * The relations that this entity has with other entities. + */ + relations?: EntityRelation[]; }; /** @@ -120,3 +126,38 @@ export type EntityMeta = JsonObject & { */ tags?: string[]; }; + +/** + * A relation of a specific type to another entity in the catalog. + */ +export type EntityRelation = { + /** + * The type of the relation. + */ + type: string; + + /** + * The target entity of this relation. + */ + target: EntityName; +}; + +/** + * Holds the relation data for entities. + */ +export type EntityRelationSpec = { + /** + * The source entity of this relation. + */ + source: EntityName; + + /** + * The type of the relation. + */ + type: string; + + /** + * The target entity of this relation. + */ + target: EntityName; +}; diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index 759a94ca67..d450119f0a 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -18,7 +18,12 @@ export { ENTITY_DEFAULT_NAMESPACE, ENTITY_META_GENERATED_FIELDS, } from './constants'; -export type { Entity, EntityMeta } from './Entity'; +export type { + Entity, + EntityMeta, + EntityRelation, + EntityRelationSpec, +} from './Entity'; export * from './policies'; export { getEntityName, diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index 20a3724079..0aabbd9e48 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -15,7 +15,12 @@ */ import { EntityPolicy } from '../../types'; -import { makeValidator, Validators } from '../../validation'; +import { + CommonValidatorFunctions, + KubernetesValidatorFunctions, + makeValidator, + Validators, +} from '../../validation'; import { Entity } from '../Entity'; /** @@ -50,7 +55,47 @@ export class FieldFormatEntityPolicy implements EntityPolicy { } if (!isValid) { - throw new Error(`${field} "${value}" is not valid`); + let expectation; + switch ( + validator.name as + | keyof typeof KubernetesValidatorFunctions + | keyof typeof CommonValidatorFunctions + ) { + case 'isValidLabelValue': + case 'isValidObjectName': + expectation = + 'a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total'; + break; + case 'isValidLabelKey': + case 'isValidApiVersion': + case 'isValidAnnotationKey': + expectation = 'a valid prefix and/or suffix'; + break; + case 'isValidNamespace': + case 'isValidDnsLabel': + expectation = + 'a string that is sequences of [a-z0-9] separated by [-], at most 63 characters in total'; + break; + case 'isValidAnnotationValue': + expectation = 'a string'; + break; + case 'isValidKind': + expectation = + 'a string that is a sequence of [a-zA-Z][a-z0-9A-Z], at most 63 characters in total'; + break; + default: + expectation = undefined; + break; + } + + // ensure that if there are other/future validators, the error message defaults to a general "is not valid, visit link" + const message = expectation + ? ` expected ${expectation} but found "${value}".` + : ''; + + throw new Error( + `"${field}" is not valid;${message} To learn more about catalog file format, visit: https://github.com/backstage/backstage/blob/master/docs/architecture-decisions/adr002-default-catalog-file-format.md`, + ); } } diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts deleted file mode 100644 index 5d4e60a1a3..0000000000 --- a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import yaml from 'yaml'; -import { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy'; - -describe('ReservedFieldsEntityPolicy', () => { - let data: any; - let policy: ReservedFieldsEntityPolicy; - - beforeEach(() => { - data = yaml.parse(` - apiVersion: backstage.io/v1alpha1 - kind: Component - metadata: - uid: e01199ab-08cc-44c2-8e19-5c29ded82521 - etag: lsndfkjsndfkjnsdfkjnsd== - generation: 13 - name: my-component-yay - namespace: the-namespace - labels: - backstage.io/custom: ValueStuff - annotations: - example.com/bindings: are-secret - tags: - - java - - data - spec: - custom: stuff - `); - policy = new ReservedFieldsEntityPolicy(); - }); - - it('works for the happy path', async () => { - await expect(policy.enforce(data)).resolves.toBe(data); - }); - - it('rejects reserved keys in the spec root', async () => { - data.spec.apiVersion = 'a/b'; - await expect(policy.enforce(data)).rejects.toThrow(/spec.*apiVersion/i); - }); - - it('rejects reserved keys in labels', async () => { - data.metadata.labels.apiVersion = 'a'; - await expect(policy.enforce(data)).rejects.toThrow(/label.*apiVersion/i); - }); - - it('rejects reserved keys in annotations', async () => { - data.metadata.annotations.apiVersion = 'a'; - await expect(policy.enforce(data)).rejects.toThrow( - /annotation.*apiVersion/i, - ); - }); - - it('rejects core fields mistakenly placed in metadata', async () => { - data.metadata.owner = 'emma'; - await expect(policy.enforce(data)).rejects.toThrow(/owner/i); - }); -}); diff --git a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts deleted file mode 100644 index edb63cf05c..0000000000 --- a/packages/catalog-model/src/entity/policies/ReservedFieldsEntityPolicy.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { EntityPolicy } from '../../types'; -import { Entity } from '../Entity'; - -const DEFAULT_RESERVED_ENTITY_FIELDS: string[] = [ - 'apiVersion', - 'kind', - 'spec', - 'metadata.uid', - 'metadata.etag', - 'metadata.generation', - 'metadata.name', - 'metadata.namespace', - 'metadata.description', - 'metadata.labels', - 'metadata.annotations', - 'metadata.tags', - // The below items are known to appear in core kinds, and therefore should - // not be appearing in metadata (which would indicate that the user made a - // mistake in where to place them). - 'spec.lifecycle', - 'spec.owner', -]; - -/** - * Ensures that fields are not given certain reserved names. - */ -export class ReservedFieldsEntityPolicy implements EntityPolicy { - private readonly reservedFields: string[]; - - constructor(fields?: string[]) { - this.reservedFields = [ - ...(fields ?? []), - ...DEFAULT_RESERVED_ENTITY_FIELDS, - ]; - } - - async enforce(entity: Entity): Promise { - for (const path of this.reservedFields) { - const [where, name] = path.includes('.') - ? path.split('.') - : [undefined, path]; - - if (where !== 'metadata' && entity.metadata.hasOwnProperty(name)) { - throw new Error( - `The metadata may not contain the field ${name}, because it has reserved meaning`, - ); - } - if (where !== 'spec' && entity.spec?.hasOwnProperty(name)) { - throw new Error( - `The spec may not contain the field ${name}, because it has reserved meaning`, - ); - } - if (where !== 'labels' && entity.metadata.labels?.hasOwnProperty(name)) { - throw new Error( - `A label may not have the field ${name}, because it has reserved meaning`, - ); - } - if ( - where !== 'annotations' && - entity.metadata.annotations?.hasOwnProperty(name) - ) { - throw new Error( - `An annotation may not have the field ${name}, because it has reserved meaning`, - ); - } - } - return entity; - } -} diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts index a6d376b4ac..4ff73e9a49 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts @@ -18,24 +18,26 @@ import * as yup from 'yup'; import { EntityPolicy } from '../../types'; import { Entity } from '../Entity'; -const DEFAULT_ENTITY_SCHEMA = yup.object({ - apiVersion: yup.string().required(), - kind: yup.string().required(), - metadata: yup - .object({ - uid: yup.string().notRequired().min(1), - etag: yup.string().notRequired().min(1), - generation: yup.number().notRequired().integer().min(1), - name: yup.string().required(), - namespace: yup.string().notRequired(), - description: yup.string().notRequired(), - labels: yup.object>().notRequired(), - annotations: yup.object>().notRequired(), - tags: yup.array().notRequired(), - }) - .required(), - spec: yup.object({}).notRequired(), -}); +const DEFAULT_ENTITY_SCHEMA = yup + .object({ + apiVersion: yup.string().required(), + kind: yup.string().required(), + metadata: yup + .object({ + uid: yup.string().notRequired().min(1), + etag: yup.string().notRequired().min(1), + generation: yup.number().notRequired().integer().min(1), + name: yup.string().required(), + namespace: yup.string().notRequired(), + description: yup.string().notRequired(), + labels: yup.object>().notRequired(), + annotations: yup.object>().notRequired(), + tags: yup.array().notRequired(), + }) + .required(), + spec: yup.object({}).notRequired(), + }) + .required(); /** * Ensures that the entity spec is valid according to a schema. diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts index eb7c0e8378..c381d29a23 100644 --- a/packages/catalog-model/src/entity/policies/index.ts +++ b/packages/catalog-model/src/entity/policies/index.ts @@ -17,5 +17,4 @@ export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy'; export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy'; export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy'; -export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy'; export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy'; diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts index 142a8d8160..a5d5152fab 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { ApiEntityV1alpha1, - apiEntityV1alpha1Policy as policy, + apiEntityV1alpha1Validator as validator, } from './ApiEntityV1alpha1'; -describe('ApiV1alpha1Policy', () => { +describe('ApiV1alpha1Validator', () => { let entity: ApiEntityV1alpha1; beforeEach(() => { @@ -75,81 +75,81 @@ components: }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects wrong type', async () => { (entity as any).spec.type = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects missing lifecycle', async () => { delete (entity as any).spec.lifecycle; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects wrong lifecycle', async () => { (entity as any).spec.lifecycle = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects empty lifecycle', async () => { (entity as any).spec.lifecycle = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects missing owner', async () => { delete (entity as any).spec.owner; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects wrong owner', async () => { (entity as any).spec.owner = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects empty owner', async () => { (entity as any).spec.owner = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects missing definition', async () => { delete (entity as any).spec.definition; - await expect(policy.enforce(entity)).rejects.toThrow(/definition/); + await expect(validator.check(entity)).rejects.toThrow(/definition/); }); it('rejects wrong definition', async () => { (entity as any).spec.definition = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/definition/); + await expect(validator.check(entity)).rejects.toThrow(/definition/); }); it('rejects empty definition', async () => { (entity as any).spec.definition = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/definition/); + await expect(validator.check(entity)).rejects.toThrow(/definition/); }); }); diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 052172d0a1..660cd71cd8 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'API' as const; @@ -45,4 +45,8 @@ export interface ApiEntityV1alpha1 extends Entity { }; } -export const apiEntityV1alpha1Policy = schemaPolicy(KIND, API_VERSION, schema); +export const apiEntityV1alpha1Validator = schemaValidator( + KIND, + API_VERSION, + schema, +); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts index 57d6d47388..64f9d05978 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { ComponentEntityV1alpha1, - componentEntityV1alpha1Policy as policy, + componentEntityV1alpha1Validator as validator, } from './ComponentEntityV1alpha1'; -describe('ComponentV1alpha1Policy', () => { +describe('ComponentV1alpha1Validator', () => { let entity: ComponentEntityV1alpha1; beforeEach(() => { @@ -39,66 +39,86 @@ describe('ComponentV1alpha1Policy', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects wrong type', async () => { (entity as any).spec.type = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects missing lifecycle', async () => { delete (entity as any).spec.lifecycle; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects wrong lifecycle', async () => { (entity as any).spec.lifecycle = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects empty lifecycle', async () => { (entity as any).spec.lifecycle = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/lifecycle/); + await expect(validator.check(entity)).rejects.toThrow(/lifecycle/); }); it('rejects missing owner', async () => { delete (entity as any).spec.owner; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects wrong owner', async () => { (entity as any).spec.owner = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); }); it('rejects empty owner', async () => { (entity as any).spec.owner = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/owner/); + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('accepts missing implementsApis', async () => { + delete (entity as any).spec.implementsApis; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects empty implementsApis', async () => { + (entity as any).spec.implementsApis = ['']; + await expect(validator.check(entity)).rejects.toThrow(/implementsApis/); + }); + + it('rejects undefined implementsApis', async () => { + (entity as any).spec.implementsApis = [undefined]; + await expect(validator.check(entity)).rejects.toThrow(/implementsApis/); + }); + + it('accepts no implementsApis', async () => { + (entity as any).spec.implementsApis = []; + await expect(validator.check(entity)).resolves.toBe(true); }); }); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 9efc491ca9..9d46dea03f 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Component' as const; @@ -29,7 +29,7 @@ const schema = yup.object>({ type: yup.string().required().min(1), lifecycle: yup.string().required().min(1), owner: yup.string().required().min(1), - implementsApis: yup.array(yup.string()).notRequired(), + implementsApis: yup.array(yup.string().required()).notRequired(), }) .required(), }); @@ -45,7 +45,7 @@ export interface ComponentEntityV1alpha1 extends Entity { }; } -export const componentEntityV1alpha1Policy = schemaPolicy( +export const componentEntityV1alpha1Validator = schemaValidator( KIND, API_VERSION, schema, diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts index dc0ecd3fa3..8b7bef8ff7 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { GroupEntityV1alpha1, - groupEntityV1alpha1Policy as policy, + groupEntityV1alpha1Validator as validator, } from './GroupEntityV1alpha1'; -describe('GroupV1alpha1Policy', () => { +describe('GroupV1alpha1Validator', () => { let entity: GroupEntityV1alpha1; beforeEach(() => { @@ -42,91 +42,106 @@ describe('GroupV1alpha1Policy', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects wrong type', async () => { (entity as any).spec.type = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('accepts missing parent', async () => { delete (entity as any).spec.parent; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects empty parent', async () => { (entity as any).spec.parent = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/parent/); + await expect(validator.check(entity)).rejects.toThrow(/parent/); }); it('rejects missing ancestors', async () => { delete (entity as any).spec.ancestors; - await expect(policy.enforce(entity)).rejects.toThrow(/ancestor/); + await expect(validator.check(entity)).rejects.toThrow(/ancestor/); }); - it('accepts empty ancestors', async () => { + it('rejects empty ancestors', async () => { (entity as any).spec.ancestors = ['']; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).rejects.toThrow(/ancestor/); + }); + + it('rejects undefined ancestors', async () => { + (entity as any).spec.ancestors = [undefined]; + await expect(validator.check(entity)).rejects.toThrow(/ancestor/); }); it('accepts no ancestors', async () => { (entity as any).spec.ancestors = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects missing children', async () => { delete (entity as any).spec.children; - await expect(policy.enforce(entity)).rejects.toThrow(/children/); + await expect(validator.check(entity)).rejects.toThrow(/children/); }); - it('accepts empty children', async () => { + it('rejects empty children', async () => { (entity as any).spec.children = ['']; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).rejects.toThrow(/children/); + }); + + it('rejects undefined children', async () => { + (entity as any).spec.children = [undefined]; + await expect(validator.check(entity)).rejects.toThrow(/children/); }); it('accepts no children', async () => { (entity as any).spec.children = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects missing descendants', async () => { delete (entity as any).spec.descendants; - await expect(policy.enforce(entity)).rejects.toThrow(/descendants/); + await expect(validator.check(entity)).rejects.toThrow(/descendants/); }); - it('accepts empty descendants', async () => { + it('rejects empty descendants', async () => { (entity as any).spec.descendants = ['']; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).rejects.toThrow(/descendants/); + }); + + it('rejects undefined descendants', async () => { + (entity as any).spec.descendants = [undefined]; + await expect(validator.check(entity)).rejects.toThrow(/descendants/); }); it('accepts no descendants', async () => { (entity as any).spec.descendants = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); }); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index 073332d707..f6345741f7 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Group' as const; @@ -30,21 +30,23 @@ const schema = yup.object>({ parent: yup.string().notRequired().min(1), // Use these manual tests because yup .required() requires at least // one element and there is no simple workaround -_- - ancestors: yup.array(yup.string()).test({ + // the cast is there to convince typescript that the array itself is + // required without using .required() + ancestors: yup.array(yup.string().required()).test({ name: 'isDefined', message: 'ancestors must be defined', test: v => Boolean(v), - }), - children: yup.array(yup.string()).test({ + }) as yup.ArraySchema, + children: yup.array(yup.string().required()).test({ name: 'isDefined', message: 'children must be defined', test: v => Boolean(v), - }), - descendants: yup.array(yup.string()).test({ + }) as yup.ArraySchema, + descendants: yup.array(yup.string().required()).test({ name: 'isDefined', message: 'descendants must be defined', test: v => Boolean(v), - }), + }) as yup.ArraySchema, }) .required(), }); @@ -61,7 +63,7 @@ export interface GroupEntityV1alpha1 extends Entity { }; } -export const groupEntityV1alpha1Policy = schemaPolicy( +export const groupEntityV1alpha1Validator = schemaValidator( KIND, API_VERSION, schema, diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts index a259eaeca8..d9c1e9185f 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { LocationEntityV1alpha1, - locationEntityV1alpha1Policy as policy, + locationEntityV1alpha1Validator as validator, } from './LocationEntityV1alpha1'; -describe('LocationV1alpha1Policy', () => { +describe('LocationV1alpha1Validator', () => { let entity: LocationEntityV1alpha1; beforeEach(() => { @@ -36,65 +36,70 @@ describe('LocationV1alpha1Policy', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects wrong type', async () => { (entity as any).spec.type = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('accepts good target', async () => { (entity as any).spec.target = - 'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + 'https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml'; + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects wrong target', async () => { (entity as any).spec.target = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/target/); + await expect(validator.check(entity)).rejects.toThrow(/target/); }); it('rejects empty target', async () => { (entity as any).spec.target = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/target/); + await expect(validator.check(entity)).rejects.toThrow(/target/); }); it('accepts good targets', async () => { (entity as any).spec.targets = [ - 'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml', - 'https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/playback-order-component.yaml', + 'https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/examples/artist-lookup-component.yaml', + 'https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/examples/playback-order-component.yaml', ]; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('accepts empty targets', async () => { + (entity as any).spec.targets = []; + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects wrong targets', async () => { (entity as any).spec.targets = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/targets/); + await expect(validator.check(entity)).rejects.toThrow(/targets/); }); }); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index 6d1f1f5515..e536be7922 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Location' as const; @@ -28,7 +28,7 @@ const schema = yup.object>({ .object({ type: yup.string().required().min(1), target: yup.string().notRequired().min(1), - targets: yup.array(yup.string()).notRequired(), + targets: yup.array(yup.string().required()).notRequired(), }) .required(), }); @@ -43,7 +43,7 @@ export interface LocationEntityV1alpha1 extends Entity { }; } -export const locationEntityV1alpha1Policy = schemaPolicy( +export const locationEntityV1alpha1Validator = schemaValidator( KIND, API_VERSION, schema, diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts index 86351b41aa..e3d29aad4f 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { TemplateEntityV1alpha1, - templateEntityV1alpha1Policy as policy, + templateEntityV1alpha1Validator as validator, } from './TemplateEntityV1alpha1'; -describe('templateEntityV1alpha1', () => { +describe('templateEntityV1alpha1Validator', () => { let entity: TemplateEntityV1alpha1; beforeEach(() => { @@ -53,41 +53,41 @@ describe('templateEntityV1alpha1', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('rejects missing type', async () => { delete (entity as any).spec.type; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('accepts any other type', async () => { (entity as any).spec.type = 'hallo'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects empty type', async () => { (entity as any).spec.type = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/type/); + await expect(validator.check(entity)).rejects.toThrow(/type/); }); it('rejects missing templater', async () => { (entity as any).spec.templater = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/templater/); + await expect(validator.check(entity)).rejects.toThrow(/templater/); }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index a26540fda2..b16fcdc7e4 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -17,7 +17,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; import type { JSONSchema } from '../types'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Template' as const; @@ -46,7 +46,7 @@ export interface TemplateEntityV1alpha1 extends Entity { }; } -export const templateEntityV1alpha1Policy = schemaPolicy( +export const templateEntityV1alpha1Validator = schemaValidator( KIND, API_VERSION, schema, diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts index 97fe0888f1..075f97e92c 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts @@ -16,10 +16,10 @@ import { UserEntityV1alpha1, - userEntityV1alpha1Policy as policy, + userEntityV1alpha1Validator as validator, } from './UserEntityV1alpha1'; -describe('userEntityV1alpha1Policy', () => { +describe('userEntityV1alpha1Validator', () => { let entity: UserEntityV1alpha1; beforeEach(() => { @@ -41,117 +41,117 @@ describe('userEntityV1alpha1Policy', () => { }); it('happy path: accepts valid data', async () => { - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); // root it('silently accepts v1beta1 as well', async () => { (entity as any).apiVersion = 'backstage.io/v1beta1'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).resolves.toBeUndefined(); + await expect(validator.check(entity)).resolves.toBe(false); }); it('spec accepts unknown additional fields', async () => { (entity as any).spec.foo = 'data'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); // profile it('accepts missing profile', async () => { delete (entity as any).spec.profile; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects wrong profile', async () => { (entity as any).spec.profile = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/profile/); + await expect(validator.check(entity)).rejects.toThrow(/profile/); }); it('profile accepts missing displayName', async () => { delete (entity as any).spec.profile.displayName; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('profile rejects wrong displayName', async () => { (entity as any).spec.profile.displayName = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/displayName/); + await expect(validator.check(entity)).rejects.toThrow(/displayName/); }); it('profile rejects empty displayName', async () => { (entity as any).spec.profile.displayName = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/displayName/); + await expect(validator.check(entity)).rejects.toThrow(/displayName/); }); it('profile accepts missing email', async () => { delete (entity as any).spec.profile.email; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('profile rejects wrong email', async () => { (entity as any).spec.profile.email = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/email/); + await expect(validator.check(entity)).rejects.toThrow(/email/); }); it('profile rejects empty email', async () => { (entity as any).spec.profile.email = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/email/); + await expect(validator.check(entity)).rejects.toThrow(/email/); }); it('profile accepts missing picture', async () => { delete (entity as any).spec.profile.picture; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('profile rejects wrong picture', async () => { (entity as any).spec.profile.picture = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/picture/); + await expect(validator.check(entity)).rejects.toThrow(/picture/); }); it('profile rejects empty picture', async () => { (entity as any).spec.profile.picture = ''; - await expect(policy.enforce(entity)).rejects.toThrow(/picture/); + await expect(validator.check(entity)).rejects.toThrow(/picture/); }); it('profile accepts unknown additional fields', async () => { (entity as any).spec.profile.foo = 'data'; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); // memberOf it('rejects missing memberOf', async () => { delete (entity as any).spec.memberOf; - await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + await expect(validator.check(entity)).rejects.toThrow(/memberOf/); }); it('rejects wrong memberOf', async () => { (entity as any).spec.memberOf = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + await expect(validator.check(entity)).rejects.toThrow(/memberOf/); }); it('rejects wrong memberOf item', async () => { (entity as any).spec.memberOf[0] = 7; - await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + await expect(validator.check(entity)).rejects.toThrow(/memberOf/); }); it('accepts empty memberOf', async () => { (entity as any).spec.memberOf = []; - await expect(policy.enforce(entity)).resolves.toBe(entity); + await expect(validator.check(entity)).resolves.toBe(true); }); it('rejects null memberOf', async () => { (entity as any).spec.memberOf = null; - await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + await expect(validator.check(entity)).rejects.toThrow(/memberOf/); }); }); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts index ff4edcc8a2..16a5a86e05 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaPolicy } from './util'; +import { schemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'User' as const; @@ -35,11 +35,13 @@ const schema = yup.object>({ .notRequired(), // Use this manual test because yup .required() requires at least one // element and there is no simple workaround -_- - memberOf: yup.array(yup.string()).test({ + // the cast is there to convince typescript that the array itself is + // required without using .required() + memberOf: yup.array(yup.string().required()).test({ name: 'isDefined', message: 'memberOf must be defined', test: v => Boolean(v), - }), + }) as yup.ArraySchema, }) .required(), }); @@ -57,4 +59,8 @@ export interface UserEntityV1alpha1 extends Entity { }; } -export const userEntityV1alpha1Policy = schemaPolicy(KIND, API_VERSION, schema); +export const userEntityV1alpha1Validator = schemaValidator( + KIND, + API_VERSION, + schema, +); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 6df4f1212a..914d7efea7 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -14,32 +14,34 @@ * limitations under the License. */ -export { apiEntityV1alpha1Policy } from './ApiEntityV1alpha1'; +export { apiEntityV1alpha1Validator } from './ApiEntityV1alpha1'; export type { ApiEntityV1alpha1 as ApiEntity, ApiEntityV1alpha1, } from './ApiEntityV1alpha1'; -export { componentEntityV1alpha1Policy } from './ComponentEntityV1alpha1'; +export { componentEntityV1alpha1Validator } from './ComponentEntityV1alpha1'; export type { ComponentEntityV1alpha1 as ComponentEntity, ComponentEntityV1alpha1, } from './ComponentEntityV1alpha1'; -export { groupEntityV1alpha1Policy } from './GroupEntityV1alpha1'; +export { groupEntityV1alpha1Validator } from './GroupEntityV1alpha1'; export type { GroupEntityV1alpha1 as GroupEntity, GroupEntityV1alpha1, } from './GroupEntityV1alpha1'; -export { locationEntityV1alpha1Policy } from './LocationEntityV1alpha1'; +export { locationEntityV1alpha1Validator } from './LocationEntityV1alpha1'; export type { LocationEntityV1alpha1 as LocationEntity, LocationEntityV1alpha1, } from './LocationEntityV1alpha1'; -export { templateEntityV1alpha1Policy } from './TemplateEntityV1alpha1'; +export * from './relations'; +export { templateEntityV1alpha1Validator } from './TemplateEntityV1alpha1'; export type { TemplateEntityV1alpha1 as TemplateEntity, TemplateEntityV1alpha1, } from './TemplateEntityV1alpha1'; -export { userEntityV1alpha1Policy } from './UserEntityV1alpha1'; +export type { KindValidator } from './types'; +export { userEntityV1alpha1Validator } from './UserEntityV1alpha1'; export type { UserEntityV1alpha1 as UserEntity, UserEntityV1alpha1, diff --git a/packages/catalog-model/src/kinds/relations.ts b/packages/catalog-model/src/kinds/relations.ts new file mode 100644 index 0000000000..846313ec8c --- /dev/null +++ b/packages/catalog-model/src/kinds/relations.ts @@ -0,0 +1,55 @@ +/* + * 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. + */ + +/* +Naming rules for relations in priority order: + +1. Use at most two words. One main verb and a specifier, e.g. "ownerOf" +2. Reading out " " should make sense in English. +3. Maintain symmetry between pairs, e.g. "ownedBy" and "ownerOf" rather than "owns". +*/ + +/** + * An ownership relation where the owner is usually an organizational + * entity (user or group), and the other entity can be anything. + */ +export const RELATION_OWNED_BY = 'ownedBy'; +export const RELATION_OWNER_OF = 'ownerOf'; + +/** + * A relation with an API entity, typically from a component or system + */ +export const RELATION_CONSUMES_API = 'consumesApi'; +export const RELATION_PROVIDES_API = 'providesApi'; + +/** + * A relation denoting a dependency on another entity. + */ +export const RELATION_DEPENDS_ON = 'dependsOn'; +export const RELATION_DEPENDENCY_OF = 'dependencyOf'; + +/** + * A parent/child relation to build up a tree, used for example to describe + * the organizational structure between groups. + */ +export const RELATION_PARENT_OF = 'parentOf'; +export const RELATION_CHILD_OF = 'childOf'; + +/** + * A membership relation, typically for users in a group. + */ +export const RELATION_MEMBER_OF = 'memberOf'; +export const RELATION_HAS_MEMBER = 'hasMember'; diff --git a/packages/catalog-model/src/kinds/types.ts b/packages/catalog-model/src/kinds/types.ts new file mode 100644 index 0000000000..4b947680c7 --- /dev/null +++ b/packages/catalog-model/src/kinds/types.ts @@ -0,0 +1,34 @@ +/* + * 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 } from '../entity'; + +/** + * Validates entities of a certain kind. + */ +export type KindValidator = { + /** + * Validates the entity as a known entity kind. + * + * @param entity The entity to validate + * @returns Resolves to true, if the entity was of a kind that was known and + * handled by this validator, and was found to be valid. Resolves to false, + * if the entity was not of a kind that was known by this validator. + * Rejects to an Error describing the problem, if the entity was of a kind + * that was known by this validator and was not valid. + */ + check(entity: Entity): Promise; +}; diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts index 491b2c5e2f..f1b02cfba1 100644 --- a/packages/catalog-model/src/kinds/util.ts +++ b/packages/catalog-model/src/kinds/util.ts @@ -15,23 +15,23 @@ */ import * as yup from 'yup'; -import { Entity } from '../entity'; -import { EntityPolicy } from '../types'; +import { KindValidator } from './types'; -export function schemaPolicy( +export function schemaValidator( kind: string, apiVersion: readonly string[], schema: yup.Schema, -): EntityPolicy { +): KindValidator { return { - async enforce(envelope: Entity): Promise { + async check(envelope) { if ( kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion as any) ) { - return undefined; + return false; } - return await schema.validate(envelope, { strict: true }); + await schema.validate(envelope, { strict: true }); + return true; }, }; } diff --git a/packages/catalog-model/src/location/types.ts b/packages/catalog-model/src/location/types.ts index 50e6e82a54..33e443e04f 100644 --- a/packages/catalog-model/src/location/types.ts +++ b/packages/catalog-model/src/location/types.ts @@ -17,6 +17,10 @@ export type LocationSpec = { type: string; target: string; + // When using repo importer plugin, location is being created before the component yaml file is merged to the main branch. + // This flag is then set to indicate that the file can be not present. + // default value: 'required'. + presence?: 'optional' | 'required'; }; export type Location = { diff --git a/packages/catalog-model/src/location/validation.ts b/packages/catalog-model/src/location/validation.ts index 5fad47bdd0..27d74b9a82 100644 --- a/packages/catalog-model/src/location/validation.ts +++ b/packages/catalog-model/src/location/validation.ts @@ -21,8 +21,10 @@ export const locationSpecSchema = yup .object({ type: yup.string().required(), target: yup.string().required(), + presence: yup.string(), }) - .noUnknown(); + .noUnknown() + .required(); export const locationSchema = yup .object({ @@ -30,4 +32,5 @@ export const locationSchema = yup type: yup.string().required(), target: yup.string().required(), }) - .noUnknown(); + .noUnknown() + .required(); diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts index 457ef64611..129fdd2b62 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts @@ -113,7 +113,7 @@ describe('CommonValidatorFunctions', () => { [{}, true], [{ a: 1 }, true], [{ a: undefined }, false], - ] as [any, boolean][])(`isJsonSafe %p ? %p`, (value, result) => { + ] as [unknown, boolean][])(`isJsonSafe %p ? %p`, (value, result) => { expect(CommonValidatorFunctions.isJsonSafe(value)).toBe(result); }); diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts index 349d6d7f72..90b4e1a611 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts @@ -31,7 +31,7 @@ export class CommonValidatorFunctions { * @param isValidSuffix Checks that the part after the separator (or the entire value if there is no separator) is valid */ static isValidPrefixAndOrSuffix( - value: any, + value: unknown, separator: string, isValidPrefix: (value: string) => boolean, isValidSuffix: (value: string) => boolean, @@ -55,7 +55,7 @@ export class CommonValidatorFunctions { * * @param value The value to check */ - static isJsonSafe(value: any): boolean { + static isJsonSafe(value: unknown): boolean { try { return lodash.isEqual(value, JSON.parse(JSON.stringify(value))); } catch { @@ -69,7 +69,7 @@ export class CommonValidatorFunctions { * @param value The value to check * @see https://tools.ietf.org/html/rfc1123 */ - static isValidDnsSubdomain(value: any): boolean { + static isValidDnsSubdomain(value: unknown): boolean { return ( typeof value === 'string' && value.length >= 1 && @@ -84,7 +84,7 @@ export class CommonValidatorFunctions { * @param value The value to check * @see https://tools.ietf.org/html/rfc1123 */ - static isValidDnsLabel(value: any): boolean { + static isValidDnsLabel(value: unknown): boolean { return ( typeof value === 'string' && value.length >= 1 && diff --git a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts index fa938f5fcb..ada0aa71ff 100644 --- a/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/KubernetesValidatorFunctions.ts @@ -25,7 +25,7 @@ import { CommonValidatorFunctions } from './CommonValidatorFunctions'; * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/#syntax-and-character-set */ export class KubernetesValidatorFunctions { - static isValidApiVersion(value: any): boolean { + static isValidApiVersion(value: unknown): boolean { return CommonValidatorFunctions.isValidPrefixAndOrSuffix( value, '/', @@ -34,7 +34,7 @@ export class KubernetesValidatorFunctions { ); } - static isValidKind(value: any): boolean { + static isValidKind(value: unknown): boolean { return ( typeof value === 'string' && value.length >= 1 && @@ -43,7 +43,7 @@ export class KubernetesValidatorFunctions { ); } - static isValidObjectName(value: any): boolean { + static isValidObjectName(value: unknown): boolean { return ( typeof value === 'string' && value.length >= 1 && @@ -52,11 +52,11 @@ export class KubernetesValidatorFunctions { ); } - static isValidNamespace(value: any): boolean { + static isValidNamespace(value: unknown): boolean { return CommonValidatorFunctions.isValidDnsLabel(value); } - static isValidLabelKey(value: any): boolean { + static isValidLabelKey(value: unknown): boolean { return CommonValidatorFunctions.isValidPrefixAndOrSuffix( value, '/', @@ -65,13 +65,13 @@ export class KubernetesValidatorFunctions { ); } - static isValidLabelValue(value: any): boolean { + static isValidLabelValue(value: unknown): boolean { return ( value === '' || KubernetesValidatorFunctions.isValidObjectName(value) ); } - static isValidAnnotationKey(value: any): boolean { + static isValidAnnotationKey(value: unknown): boolean { return CommonValidatorFunctions.isValidPrefixAndOrSuffix( value, '/', @@ -80,7 +80,7 @@ export class KubernetesValidatorFunctions { ); } - static isValidAnnotationValue(value: any): boolean { + static isValidAnnotationValue(value: unknown): boolean { return typeof value === 'string'; } } diff --git a/packages/catalog-model/src/validation/types.ts b/packages/catalog-model/src/validation/types.ts index 14706485ca..a4475c4809 100644 --- a/packages/catalog-model/src/validation/types.ts +++ b/packages/catalog-model/src/validation/types.ts @@ -15,13 +15,13 @@ */ export type Validators = { - isValidApiVersion(value: any): boolean; - isValidKind(value: any): boolean; - isValidEntityName(value: any): boolean; - isValidNamespace(value: any): boolean; - isValidLabelKey(value: any): boolean; - isValidLabelValue(value: any): boolean; - isValidAnnotationKey(value: any): boolean; - isValidAnnotationValue(value: any): boolean; - isValidTag(value: any): boolean; + isValidApiVersion(value: unknown): boolean; + isValidKind(value: unknown): boolean; + isValidEntityName(value: unknown): boolean; + isValidNamespace(value: unknown): boolean; + isValidLabelKey(value: unknown): boolean; + isValidLabelValue(value: unknown): boolean; + isValidAnnotationKey(value: unknown): boolean; + isValidAnnotationValue(value: unknown): boolean; + isValidTag(value: unknown): boolean; }; diff --git a/packages/cli-common/README.md b/packages/cli-common/README.md index 3488003a76..91b774145e 100644 --- a/packages/cli-common/README.md +++ b/packages/cli-common/README.md @@ -8,5 +8,5 @@ Do not install this package directly, it is an internal package used by [@backst ## Documentation -- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index abb4b5f9a1..0d929e53e2 100644 --- a/packages/cli-common/package.json +++ b/packages/cli-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli-common", "description": "Common functionality used by cli, backend, and create-app", - "version": "0.1.1-alpha.24", + "version": "0.1.1", "private": false, "main": "src/index.ts", "types": "src/index.ts", @@ -13,7 +13,7 @@ "homepage": "https://backstage.io", "repository": { "type": "git", - "url": "https://github.com/spotify/backstage", + "url": "https://github.com/backstage/backstage", "directory": "packages/cli-common" }, "keywords": [ diff --git a/packages/cli-common/src/paths.test.ts b/packages/cli-common/src/paths.test.ts index 087f95af38..2278ee3581 100644 --- a/packages/cli-common/src/paths.test.ts +++ b/packages/cli-common/src/paths.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +/* eslint-disable no-restricted-syntax */ import { resolve as resolvePath } from 'path'; import { findPaths, findRootPath, findOwnDir, findOwnRootDir } from './paths'; diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md new file mode 100644 index 0000000000..a5bcc6bcfd --- /dev/null +++ b/packages/cli/CHANGELOG.md @@ -0,0 +1,79 @@ +# @backstage/cli + +## 0.3.0 + +### Minor Changes + +- 1722cb53c: Added support for loading and validating configuration schemas, as well as declaring config visibility through schemas. + + The new `loadConfigSchema` function exported by `@backstage/config-loader` allows for the collection and merging of configuration schemas from all nearby dependencies of the project. + + A configuration schema is declared using the `https://backstage.io/schema/config-v1` JSON Schema meta schema, which is based on draft07. The only difference to the draft07 schema is the custom `visibility` keyword, which is used to indicate whether the given config value should be visible in the frontend or not. The possible values are `frontend`, `backend`, and `secret`, where `backend` is the default. A visibility of `secret` has the same scope at runtime, but it will be treated with more care in certain contexts, and defining both `frontend` and `secret` for the same value in two different schemas will result in an error during schema merging. + + Packages that wish to contribute configuration schema should declare it in a root `"configSchema"` field in `package.json`. The field can either contain an inlined JSON schema, or a relative path to a schema file. Schema files can be in either `.json` or `.d.ts` format. + + TypeScript configuration schema files should export a single `Config` type, for example: + + ```ts + export interface Config { + app: { + /** + * Frontend root URL + * @visibility frontend + */ + baseUrl: string; + }; + } + ``` + +### Patch Changes + +- 1722cb53c: Added configuration schema +- 902340451: Support specifying listen host/port for frontend +- Updated dependencies [1722cb53c] + - @backstage/config-loader@0.3.0 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI +- 1d0aec70f: Upgrade dependency `esbuild@0.7.7` +- 72f6cda35: Adds a new `BACKSTAGE_CLI_BUILD_PARELLEL` environment variable to control + parallelism for some build steps. + + This is useful in CI to help avoid out of memory issues when using `terser`. The + `BACKSTAGE_CLI_BUILD_PARELLEL` environment variable can be set to + `true | false | [integer]` to override the default behaviour. See + [terser-webpack-plugin](https://github.com/webpack-contrib/terser-webpack-plugin#parallel) + for more details. + +- 8c2b76e45: **BREAKING CHANGE** + + The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed. + Instead, the CLI and backend process now accept one or more `--config` flags to load config files. + + Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded. + If passing any `--config ` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one. + + The old behaviour of for example `APP_ENV=development` can be replicated using the following flags: + + ```bash + --config ../../app-config.yaml --config ../../app-config.development.yaml + ``` + +- 8afce088a: Use APP_ENV before NODE_ENV for determining what config to load + +### Patch Changes + +- 3472c8be7: Add codeowners processor + + - Include ESNext.Promise in TypeScript compilation + +- a3840bed2: Upgrade dependency rollup-plugin-typescript2 to ^0.27.3 +- cba4e4d97: Including source maps with all packages +- 9a3b3dbf1: Fixed duplicate help output, and print help on invalid command +- 7bbeb049f: Change loadBackendConfig to return the config directly +- Updated dependencies [8c2b76e45] +- Updated dependencies [ce5512bc0] + - @backstage/config-loader@0.2.0 diff --git a/packages/cli/README.md b/packages/cli/README.md index 8868e699c8..9eac5cdfe4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -30,5 +30,5 @@ To try out the command locally, you can execute the following from the parent di ## Documentation -- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/cli/bin/backstage-cli b/packages/cli/bin/backstage-cli index a065b8ac5a..5c127af2ec 100755 --- a/packages/cli/bin/backstage-cli +++ b/packages/cli/bin/backstage-cli @@ -18,6 +18,7 @@ const path = require('path'); // Figure out whether we're running inside the backstage repo or as an installed dependency +/* eslint-disable-next-line no-restricted-syntax */ const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { @@ -25,6 +26,7 @@ if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { } else { require('ts-node').register({ transpileOnly: true, + /* eslint-disable-next-line no-restricted-syntax */ project: path.resolve(__dirname, '../../../tsconfig.json'), compilerOptions: { module: 'CommonJS', diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index 113ba55818..67f0a49b9f 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -37,6 +37,10 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { + // TODO(Rugvip): We need to bump @typescript-eslint to v4 to enable these + '@typescript-eslint/no-shadow': 0, + '@typescript-eslint/no-redeclare': 0, + 'no-console': 0, // Permitted in console programs 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()' 'import/newline-after-import': 'error', @@ -67,6 +71,11 @@ module.exports = { selector: 'ImportDeclaration[source.value="winston"] ImportDefaultSpecifier', }, + { + message: + "`__dirname` doesn't refer to the same dir in production builds, try `resolvePackagePath()` from `@backstage/backend-common` instead.", + selector: 'Identifier[name="__dirname"]', + }, ], }, overrides: [ diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index 3e171d1f82..9b1880c1db 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -41,6 +41,10 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { + // TODO(Rugvip): We need to bump @typescript-eslint to v4 to enable these + '@typescript-eslint/no-shadow': 0, + '@typescript-eslint/no-redeclare': 0, + 'import/newline-after-import': 'error', 'import/no-duplicates': 'warn', 'import/no-extraneous-dependencies': [ diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index f69e18190d..61cd83b7b0 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -77,14 +77,17 @@ async function getConfig() { '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), }, + globals: { + 'ts-jest': { + isolatedModules: true, + }, + }, + // We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed // TODO: jest is working on module support, it's possible that we can remove this in the future transform: { '\\.esm\\.js$': require.resolve('jest-esm-transformer'), - '\\.(js|jsx|ts|tsx)$': [ - require.resolve('ts-jest'), - { isolatedModules: true }, - ], + '\\.(js|jsx|ts|tsx)$': require.resolve('ts-jest'), '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$': require.resolve( './jestFileTransform.js', ), @@ -96,7 +99,7 @@ async function getConfig() { // Default behaviour is to not apply transforms for node_modules, but we still want // to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages. transformIgnorePatterns: [ - `/node_modules/${transformModulePattern}(?:(?!\\.esm).)*\\.(?:js|json)$`, + `/node_modules/${transformModulePattern}.*\\.(?:(? { - const appConfigs = await loadConfig({ - env: process.env.NODE_ENV ?? 'production', - rootPaths: [paths.targetRoot, paths.targetDir], - }); await buildBundle({ entry: 'src/index', + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), statsJsonEnabled: cmd.stats, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, + ...(await loadCliConfig(cmd.config)), }); }; diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts index c748d12a75..c3f58774b5 100644 --- a/packages/cli/src/commands/app/serve.ts +++ b/packages/cli/src/commands/app/serve.ts @@ -15,21 +15,14 @@ */ import { Command } from 'commander'; -import { loadConfig } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; -import { paths } from '../../lib/paths'; import { serveBundle } from '../../lib/bundler'; +import { loadCliConfig } from '../../lib/config'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: process.env.NODE_ENV ?? 'development', - rootPaths: [paths.targetRoot, paths.targetDir], - }); const waitForExit = await serveBundle({ entry: 'src/index', checksEnabled: cmd.check, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, + ...(await loadCliConfig(cmd.config)), }); await waitForExit(); diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts index b6e9da7b39..b6ae0dd579 100644 --- a/packages/cli/src/commands/backend/buildImage.ts +++ b/packages/cli/src/commands/backend/buildImage.ts @@ -14,12 +14,13 @@ * limitations under the License. */ +import { Command } from 'commander'; import fs from 'fs-extra'; import { join as joinPath, relative as relativePath } from 'path'; import { createDistWorkspace } from '../../lib/packager'; import { paths } from '../../lib/paths'; import { run } from '../../lib/run'; -import { Command } from 'commander'; +import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; const PKG_PATH = 'package.json'; @@ -41,6 +42,7 @@ export default async (cmd: Command) => { ...appConfigs, { src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' }, ], + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), skeleton: 'skeleton.tar', }); console.log(`Dist workspace ready at ${tempDistWorkspace}`); diff --git a/packages/cli/src/commands/backend/dev.ts b/packages/cli/src/commands/backend/dev.ts index 8b2ff9285a..88c069a92b 100644 --- a/packages/cli/src/commands/backend/dev.ts +++ b/packages/cli/src/commands/backend/dev.ts @@ -14,24 +14,14 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; -import { loadConfig } from '@backstage/config-loader'; import { Command } from 'commander'; -import { paths } from '../../lib/paths'; import { serveBackend } from '../../lib/bundler/backend'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: process.env.NODE_ENV ?? 'development', - rootPaths: [paths.targetRoot, paths.targetDir], - }); - const waitForExit = await serveBackend({ entry: 'src/index', checksEnabled: cmd.check, inspectEnabled: cmd.inspect, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, }); await waitForExit(); diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts index bb626f520f..9649c70ef3 100644 --- a/packages/cli/src/commands/config/print.ts +++ b/packages/cli/src/commands/config/print.ts @@ -15,23 +15,53 @@ */ import { Command } from 'commander'; -import { loadConfig } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; -import { paths } from '../../lib/paths'; import { stringify as stringifyYaml } from 'yaml'; +import { AppConfig, ConfigReader } from '@backstage/config'; +import { loadCliConfig } from '../../lib/config'; +import { ConfigSchema, ConfigVisibility } from '@backstage/config-loader'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: cmd.env ?? process.env.NODE_ENV ?? 'development', - shouldReadSecrets: cmd.withSecrets ?? false, - rootPaths: [paths.targetRoot, paths.targetDir], - }); - - const flatConfig = ConfigReader.fromConfigs(appConfigs).get(); + const { schema, appConfigs } = await loadCliConfig(cmd.config); + const visibility = getVisiblityOption(cmd); + const data = serializeConfigData(appConfigs, schema, visibility); if (cmd.format === 'json') { - process.stdout.write(`${JSON.stringify(flatConfig, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); } else { - process.stdout.write(`${stringifyYaml(flatConfig)}\n`); + process.stdout.write(`${stringifyYaml(data)}\n`); } }; + +function getVisiblityOption(cmd: Command): ConfigVisibility { + if (cmd.frontend && cmd.withSecrets) { + throw new Error('Not allowed to combine frontend and secret config'); + } + if (cmd.frontend) { + return 'frontend'; + } else if (cmd.withSecrets) { + return 'secret'; + } + return 'backend'; +} + +function serializeConfigData( + appConfigs: AppConfig[], + schema: ConfigSchema, + visiblity: ConfigVisibility, +) { + if (visiblity === 'frontend') { + const frontendConfigs = schema.process(appConfigs, { + visiblity: ['frontend'], + }); + return ConfigReader.fromConfigs(frontendConfigs).get(); + } else if (visiblity === 'secret') { + return ConfigReader.fromConfigs(appConfigs).get(); + } + + const sanitizedConfigs = schema.process(appConfigs, { + valueTransform: (value, { visibility }) => + visibility === 'secret' ? '' : value, + }); + + return ConfigReader.fromConfigs(sanitizedConfigs).get(); +} diff --git a/packages/cli/src/commands/config/validate.ts b/packages/cli/src/commands/config/validate.ts new file mode 100644 index 0000000000..229f9e07e9 --- /dev/null +++ b/packages/cli/src/commands/config/validate.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Command } from 'commander'; +import { loadCliConfig } from '../../lib/config'; + +export default async (cmd: Command) => { + await loadCliConfig(cmd.config); +}; diff --git a/packages/cli/src/commands/create-plugin/createPlugin.test.ts b/packages/cli/src/commands/create-plugin/createPlugin.test.ts index 72b95b072c..660caec189 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.test.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.test.ts @@ -16,54 +16,27 @@ import fs from 'fs-extra'; import path from 'path'; -import os from 'os'; -import del from 'del'; -import { createTemporaryPluginFolder, movePlugin } from './createPlugin'; +import mockFs from 'mock-fs'; +import { movePlugin } from './createPlugin'; + +const id = 'testPluginMock'; describe('createPlugin', () => { - describe('createPluginFolder', () => { - it('should create a temporary plugin directory in the correct place', async () => { - const id = 'testPlugin'; - const tempDir = path.join(os.tmpdir(), id); - try { - await createTemporaryPluginFolder(tempDir); - await expect(fs.pathExists(tempDir)).resolves.toBe(true); - expect(tempDir).toMatch(id); - } finally { - await del(tempDir, { force: true }); - } - }); - - it('should not create a temporary plugin directory if it already exists', async () => { - const id = 'testPlugin'; - const tempDir = path.join(os.tmpdir(), id); - try { - await createTemporaryPluginFolder(tempDir); - await expect(fs.pathExists(tempDir)).resolves.toBe(true); - await expect(createTemporaryPluginFolder(tempDir)).rejects.toThrow( - /Failed to create temporary plugin directory/, - ); - } finally { - await del(tempDir, { force: true }); - } - }); + afterAll(() => { + mockFs.restore(); }); describe('movePlugin', () => { it('should move the temporary plugin directory to its final place', async () => { - const id = 'testPlugin'; - const tempDir = path.join(os.tmpdir(), id); - const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-')); - const pluginDir = path.join(rootDir, 'plugins', id); - try { - await createTemporaryPluginFolder(tempDir); - await movePlugin(tempDir, pluginDir, id); - await expect(fs.pathExists(pluginDir)).resolves.toBe(true); - expect(pluginDir).toMatch(path.join('', 'plugins', id)); - } finally { - await del(tempDir, { force: true }); - await del(rootDir, { force: true }); - } + mockFs({ + [id]: {}, + }); + const tempDir = id; + const pluginDir = path.join('test-temp', 'plugins', id); + + await movePlugin(tempDir, pluginDir, id); + await expect(fs.pathExists(pluginDir)).resolves.toBe(true); + expect(pluginDir).toMatch(path.join('', 'plugins', id)); }); }); }); diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index cbaf956bdb..a2005c5580 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -19,7 +19,7 @@ import { promisify } from 'util'; import chalk from 'chalk'; import inquirer, { Answers, Question } from 'inquirer'; import { exec as execCb } from 'child_process'; -import { resolve as resolvePath } from 'path'; +import { resolve as resolvePath, join as joinPath } from 'path'; import os from 'os'; import { Command } from 'commander'; import { @@ -28,8 +28,8 @@ import { getCodeownersFilePath, } from '../../lib/codeowners'; import { paths } from '../../lib/paths'; +import { packageVersions } from '../../lib/version'; import { Task, templatingTask } from '../../lib/tasks'; -import { version as backstageVersion } from '../../lib/version'; const exec = promisify(execCb); @@ -46,18 +46,6 @@ async function checkExists(destination: string) { }); } -export async function createTemporaryPluginFolder(tempDir: string) { - await Task.forItem('creating', 'temporary directory', async () => { - try { - await fs.mkdir(tempDir); - } catch (error) { - throw new Error( - `Failed to create temporary plugin directory: ${error.message}`, - ); - } - }); -} - const sortObjectByKeys = (obj: { [name in string]: string }) => { return Object.keys(obj) .sort() @@ -152,15 +140,21 @@ async function buildPlugin(pluginFolder: string) { 'yarn build', ]; for (const command of commands) { - await Task.forItem('executing', command, async () => { - process.chdir(pluginFolder); - - await exec(command).catch(error => { + try { + await Task.forItem('executing', command, async () => { + process.chdir(pluginFolder); + await exec(command); + }).catch(error => { process.stdout.write(error.stderr); process.stdout.write(error.stdout); - throw new Error(`Could not execute command ${chalk.cyan(command)}`); + throw new Error( + `Warning: Could not execute command ${chalk.cyan(command)}`, + ); }); - }); + } catch (error) { + Task.error(error.message); + break; + } } } @@ -224,9 +218,11 @@ export default async (cmd: Command) => { } const answers: Answers = await inquirer.prompt(questions); + const pluginId = cmd.backend ? `${answers.id}-backend` : answers.id; + const name = cmd.scope - ? `@${cmd.scope.replace(/^@/, '')}/plugin-${answers.id}` - : `plugin-${answers.id}`; + ? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}` + : `plugin-${pluginId}`; const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : ''; const privatePackage = cmd.private === false ? false : true; const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json')); @@ -236,54 +232,59 @@ export default async (cmd: Command) => { ? 'templates/default-backend-plugin' : 'templates/default-plugin', ); - const tempDir = resolvePath(os.tmpdir(), answers.id); const pluginDir = isMonoRepo - ? paths.resolveTargetRoot('plugins', answers.id) - : paths.resolveTargetRoot(answers.id); + ? paths.resolveTargetRoot('plugins', pluginId) + : paths.resolveTargetRoot(pluginId); const ownerIds = parseOwnerIds(answers.owner); - const { version } = isMonoRepo + const { version: pluginVersion } = isMonoRepo ? await fs.readJson(paths.resolveTargetRoot('lerna.json')) : { version: '0.1.0' }; Task.log(); Task.log('Creating the plugin...'); + Task.section('Checking if the plugin ID is available'); + await checkExists(pluginDir); + + Task.section('Creating a temporary plugin directory'); + const tempDir = await fs.mkdtemp( + joinPath(os.tmpdir(), `backstage-plugin-${pluginId}`), + ); + try { - Task.section('Checking if the plugin ID is available'); - await checkExists(pluginDir); - - Task.section('Creating a temporary plugin directory'); - await createTemporaryPluginFolder(tempDir); - Task.section('Preparing files'); - await templatingTask(templateDir, tempDir, { - ...answers, - version, - backstageVersion, - name, - privatePackage, - npmRegistry, - }); + await templatingTask( + templateDir, + tempDir, + { + ...answers, + pluginVersion, + name, + privatePackage, + npmRegistry, + }, + packageVersions, + ); Task.section('Moving to final location'); - await movePlugin(tempDir, pluginDir, answers.id); + await movePlugin(tempDir, pluginDir, pluginId); Task.section('Building the plugin'); await buildPlugin(pluginDir); if ((await fs.pathExists(appPackage)) && !cmd.backend) { Task.section('Adding plugin as dependency in app'); - await addPluginDependencyToApp(paths.targetRoot, name, version); + await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion); Task.section('Import plugin in app'); - await addPluginToApp(paths.targetRoot, answers.id, name); + await addPluginToApp(paths.targetRoot, pluginId, name); } if (ownerIds && ownerIds.length) { await addCodeownersEntry( codeownersPath!, - `/plugins/${answers.id}`, + `/plugins/${pluginId}`, ownerIds, ); } diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index fea250cd55..1ebe518cac 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -18,16 +18,25 @@ import { CommanderStatic } from 'commander'; import { exitWithError } from '../lib/errors'; export function registerCommands(program: CommanderStatic) { + const configOption = [ + '--config ', + 'Config files to load instead of app-config.yaml', + (opt: string, opts: string[]) => [...opts, opt], + Array(), + ] as const; + program .command('app:build') .description('Build an app for a production release') .option('--stats', 'Write bundle stats to output directory') + .option(...configOption) .action(lazy(() => import('./app/build').then(m => m.default))); program .command('app:serve') .description('Serve an app for local development') .option('--check', 'Enable type checking and linting') + .option(...configOption) .action(lazy(() => import('./app/serve').then(m => m.default))); program @@ -50,6 +59,8 @@ export function registerCommands(program: CommanderStatic) { .description('Start local development server with HMR for the backend') .option('--check', 'Enable type checking and linting') .option('--inspect', 'Enable debugger') + // We don't actually use the config in the CLI, just pass them on to the NodeJS process + .option(...configOption) .action(lazy(() => import('./backend/dev').then(m => m.default))); program @@ -89,14 +100,9 @@ export function registerCommands(program: CommanderStatic) { .command('plugin:serve') .description('Serves the dev/ folder of a plugin') .option('--check', 'Enable type checking and linting') + .option(...configOption) .action(lazy(() => import('./plugin/serve').then(m => m.default))); - program - .command('plugin:export') - .description('Exports the dev/ folder of a plugin') - .option('--stats', 'Write bundle stats to output directory') - .action(lazy(() => import('./plugin/export').then(m => m.default))); - program .command('plugin:diff') .option('--check', 'Fail if changes are required') @@ -130,18 +136,24 @@ export function registerCommands(program: CommanderStatic) { program .command('config:print') + .option('--frontend', 'Print only the frontend configuration') .option('--with-secrets', 'Include secrets in the printed configuration') - .option( - '--env ', - 'The environment to print configuration for [NODE_ENV or development]', - ) .option( '--format ', 'Format to print the configuration in, either json or yaml [yaml]', ) + .option(...configOption) .description('Print the app configuration for the current package') .action(lazy(() => import('./config/print').then(m => m.default))); + program + .command('config:check') + .option(...configOption) + .description( + 'Validate that the given configuration loads and matches schema', + ) + .action(lazy(() => import('./config/validate').then(m => m.default))); + program .command('prepack') .description('Prepares a package for packaging before publishing') diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts index f5dfa670b7..416d67869b 100644 --- a/packages/cli/src/commands/plugin/diff.ts +++ b/packages/cli/src/commands/plugin/diff.ts @@ -25,13 +25,12 @@ import { yesPromptFunc, } from '../../lib/diff'; import { paths } from '../../lib/paths'; -import { version as backstageVersion } from '../../lib/version'; export type PluginData = { id: string; name: string; privatePackage: string; - version: string; + pluginVersion: string; npmRegistry: string; }; @@ -40,17 +39,13 @@ const fileHandlers = [ patterns: ['package.json'], handler: handlers.packageJson, }, - { - patterns: ['tsconfig.json'], - handler: handlers.exactMatch, - }, { // make sure files in 1st level of src/ and dev/ exist patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/], handler: handlers.exists, }, { - patterns: ['README.md', /^src\//], + patterns: ['README.md', 'tsconfig.json', /^src\//], handler: handlers.skip, }, ]; @@ -66,10 +61,7 @@ export default async (cmd: Command) => { } const data = await readPluginData(); - const templateFiles = await diffTemplateFiles('default-plugin', { - backstageVersion, - ...data, - }); + const templateFiles = await diffTemplateFiles('default-plugin', data); await handleAllFiles(fileHandlers, templateFiles, promptFunc); await finalize(); }; @@ -78,13 +70,13 @@ export default async (cmd: Command) => { async function readPluginData(): Promise { let name: string; let privatePackage: string; - let version: string; + let pluginVersion: string; let npmRegistry: string; try { const pkg = require(paths.resolveTarget('package.json')); name = pkg.name; privatePackage = pkg.private; - version = pkg.version; + pluginVersion = pkg.version; const scope = name.split('/')[0]; if (`${scope}:registry` in pkg.publishConfig) { const registryURL = pkg.publishConfig[`${scope}:registry`]; @@ -106,5 +98,5 @@ async function readPluginData(): Promise { const id = pluginIdMatch[1]; - return { id, name, privatePackage, version, npmRegistry }; + return { id, name, privatePackage, pluginVersion, npmRegistry }; } diff --git a/packages/cli/src/commands/plugin/serve.ts b/packages/cli/src/commands/plugin/serve.ts index 8a04df8837..cb9def3158 100644 --- a/packages/cli/src/commands/plugin/serve.ts +++ b/packages/cli/src/commands/plugin/serve.ts @@ -15,21 +15,14 @@ */ import { Command } from 'commander'; -import { loadConfig } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; -import { paths } from '../../lib/paths'; import { serveBundle } from '../../lib/bundler'; +import { loadCliConfig } from '../../lib/config'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: process.env.NODE_ENV ?? 'development', - rootPaths: [paths.targetRoot, paths.targetDir], - }); const waitForExit = await serveBundle({ entry: 'dev/index', checksEnabled: cmd.check, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, + ...(await loadCliConfig(cmd.config)), }); await waitForExit(); diff --git a/packages/cli/src/commands/remove-plugin/file-mocks.ts b/packages/cli/src/commands/remove-plugin/file-mocks.ts new file mode 100644 index 0000000000..5768f5c398 --- /dev/null +++ b/packages/cli/src/commands/remove-plugin/file-mocks.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. + */ +export const pluginsFileContent = ` +export { plugin as WelcomePlugin } from '@backstage/plugin-welcome'; +export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';`; + +export const codeownersFileContent = ` +* @spotify/backstage-core +/docs/features/techdocs @spotify/techdocs-core +/plugins/cost-insights @spotify/silver-lining +`; + +export const packageFileContent = { + name: 'example-app', + version: '0.1.1', + dependencies: {}, + devDependencies: {}, + scripts: {}, +}; diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 3ffa8d63cf..56cde2ca9e 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -16,13 +16,9 @@ import fse from 'fs-extra'; import path from 'path'; -import os from 'os'; +import mockFs from 'mock-fs'; import { paths } from '../../lib/paths'; -import { - addExportStatement, - capitalize, - createTemporaryPluginFolder, -} from '../create-plugin/createPlugin'; +import { addExportStatement, capitalize } from '../create-plugin/createPlugin'; import { addCodeownersEntry } from '../../lib/codeowners'; import { removeReferencesFromAppPackage, @@ -31,160 +27,209 @@ import { removeSymLink, removePluginFromCodeOwners, } from './removePlugin'; +import { + codeownersFileContent, + packageFileContent, + pluginsFileContent, +} from './file-mocks'; -// Some constant variables const BACKSTAGE = `@backstage`; const testPluginName = 'yarn-test-package'; const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; -const tempDir = path.join(os.tmpdir(), 'remove-plugin-test'); +const tempDir = '/remove-plugin-test'; const removeEmptyLines = (file: string): string => file.split(/\r?\n/).filter(Boolean).join('\n'); -const createTestPackageFile = async ( - testFilePath: string, - packageFile: string, -) => { - // Copy contents of package file for test - const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8')); +const createTestPackageFile = async (testFilePath: string) => { + const testFileContent = { + ...packageFileContent, + dependencies: { + ...packageFileContent.dependencies, + [testPluginPackage]: '0.1.0', + }, + }; - packageFileContent.dependencies[testPluginPackage] = '0.1.0'; - fse.createFileSync(testFilePath); - fse.writeFileSync( - testFilePath, - `${JSON.stringify(packageFileContent, null, 2)}\n`, - 'utf8', - ); + mockFs({ + packages: { + app: { + 'package.json': `${JSON.stringify(packageFileContent, null, 2)}\n`, + }, + }, + [tempDir]: { + [testFilePath]: `${JSON.stringify(testFileContent, null, 2)}\n`, + }, + }); return; }; const createTestPluginFile = async ( - testFilePath: string, - pluginsFilePath: string, + testFileName: string, + pluginsFileName: string, ) => { - // Copy contents of package file for test - fse.copyFileSync(pluginsFilePath, testFilePath); + mockFs({ + [tempDir]: { + [testFileName]: `${pluginsFileContent}\n`, + [pluginsFileName]: `${pluginsFileContent}\n`, + }, + packages: { + app: { + src: { + 'plugin.ts': `${pluginsFileContent}\n`, + }, + }, + }, + }); + const pluginNameCapitalized = testPluginName .split('-') .map(name => capitalize(name)) .join(''); - const exportStatement = `export { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; - addExportStatement(testFilePath, exportStatement); + const exportStatement = `export { plugin as ${pluginNameCapitalized}} from ${testPluginPackage}`; + await addExportStatement(path.join(tempDir, testFileName), exportStatement); }; const mkTestPluginDir = (testDirPath: string) => { - fse.mkdirSync(testDirPath); - for (let i = 0; i < 50; i++) - fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); + const pluginFiles: { [index: string]: string } = {}; + for (let i = 0; i < 50; i++) { + pluginFiles[`testFile${i}.ts`] = ''; + } + + mockFs({ + [testDirPath]: pluginFiles, + }); }; -beforeAll(() => { - // Create temporary directory for all tests - createTemporaryPluginFolder(tempDir); -}); - describe('removePlugin', () => { - describe('Remove Plugin Dependencies', () => { - const appPath = paths.resolveTargetRoot('packages', 'app'); - const githubDir = paths.resolveTargetRoot('.github'); - it('removes plugin references from /packages/app/package.json', async () => { - // Set up test - const packageFilePath = path.join(appPath, 'package.json'); - const testFilePath = path.join(tempDir, 'test.json'); - createTestPackageFile(testFilePath, packageFilePath); - try { - await removeReferencesFromAppPackage(testFilePath, testPluginName); - const testFileContent = removeEmptyLines( - fse.readFileSync(testFilePath, 'utf8'), - ); - const packageFileContent = removeEmptyLines( - fse.readFileSync(packageFilePath, 'utf8'), - ); - expect(testFileContent).toBe(packageFileContent); - } finally { - fse.removeSync(testFilePath); - } - }); - it('removes plugin exports from /packages/app/src/packacge.json', async () => { - const testFilePath = path.join(tempDir, 'test.ts'); - const pluginsFilePaths = path.join(appPath, 'src', 'plugins.ts'); - createTestPluginFile(testFilePath, pluginsFilePaths); - try { - await removeReferencesFromPluginsFile(testFilePath, testPluginName); - const testFileContent = removeEmptyLines( - fse.readFileSync(testFilePath, 'utf8'), - ); - const pluginsFileContent = removeEmptyLines( - fse.readFileSync(pluginsFilePaths, 'utf8'), - ); - expect(testFileContent).toBe(pluginsFileContent); - } finally { - fse.removeSync(testFilePath); - } - }); - it('removes codeOwners references', async () => { - const testFilePath = path.join(tempDir, 'test'); - const codeownersPath = path.join(githubDir, 'CODEOWNERS'); - try { - fse.copySync(codeownersPath, testFilePath); - const testFileContent = removeEmptyLines( - fse.readFileSync(testFilePath, 'utf8'), - ); - const codeOwnersFileContent = removeEmptyLines( - fse.readFileSync(codeownersPath, 'utf8'), - ); - await addCodeownersEntry(testFilePath!, `/plugins/${testPluginName}`, [ - '@thisIsAtestTeam', - 'test@gmail.com', - ]); - await removePluginFromCodeOwners(testFilePath, testPluginName); - expect(testFileContent).toBe(codeOwnersFileContent); - } finally { - if (fse.existsSync(testFilePath)) fse.removeSync(testFilePath); - } + beforeAll(() => { + // Create temporary directory for all tests + mockFs({ + [tempDir]: { + 'package.json': packageFileContent, + src: { + 'plugin.ts': pluginsFileContent, + }, + }, }); }); + + afterAll(() => { + mockFs.restore(); + }); + + describe('Remove Plugin Dependencies', () => { + it('removes plugin references from /packages/app/package.json', async () => { + // Set up test + const testFilePath = 'test.json'; + createTestPackageFile(testFilePath); + await removeReferencesFromAppPackage( + path.join(tempDir, testFilePath), + testPluginName, + ); + const testFileContent = removeEmptyLines( + fse.readFileSync(path.join(tempDir, testFilePath), 'utf8'), + ); + + const mockedPackageFileContent = removeEmptyLines( + fse.readFileSync(path.join('packages', 'app', 'package.json'), 'utf8'), + ); + expect(testFileContent).toBe(mockedPackageFileContent); + }); + it('removes plugin exports from /packages/app/src/package.json', async () => { + const testFileName = 'test.ts'; + const pluginsFileName = 'plugin.ts'; + createTestPluginFile(testFileName, pluginsFileName); + await removeReferencesFromPluginsFile( + path.join(tempDir, testFileName), + testPluginName, + ); + const testFileContent = removeEmptyLines( + fse.readFileSync(path.join(tempDir, testFileName), 'utf8'), + ); + const mockedPluginsFileContent = removeEmptyLines( + fse.readFileSync( + path.join('packages', 'app', 'src', pluginsFileName), + 'utf8', + ), + ); + expect(testFileContent).toBe(mockedPluginsFileContent); + }); + + it('removes codeOwners references', async () => { + const testFileName = 'test'; + const testFilePath = path.join(tempDir, testFileName); + + const mockedCodeownersPath = path.join('.github', 'CODEOWNERS'); + + mockFs({ + [tempDir]: { + [testFileName]: '', + }, + '.github': { + CODEOWNERS: codeownersFileContent, + }, + }); + fse.copySync(mockedCodeownersPath, testFilePath); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const codeOwnersFileContent = removeEmptyLines( + fse.readFileSync(mockedCodeownersPath, 'utf8'), + ); + await addCodeownersEntry( + testFilePath!, + path.join('plugins', testPluginName), + ['@thisIsAtestTeam', 'test@gmail.com'], + ); + await removePluginFromCodeOwners(testFilePath, testPluginName); + expect(testFileContent).toBe(codeOwnersFileContent); + }); + }); + describe('Remove files', () => { const testDirPath = path.join( paths.resolveTargetRoot(), 'plugins', testPluginName, ); + describe('Removes Plugin Directory', () => { it('removes plugin directory from /plugins', async () => { - try { - mkTestPluginDir(testDirPath); - expect(fse.existsSync(testDirPath)).toBeTruthy(); - await removePluginDirectory(testDirPath); - expect(fse.existsSync(testDirPath)).toBeFalsy(); - } finally { - if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); - } + mkTestPluginDir(testDirPath); + expect(fse.existsSync(testDirPath)).toBeTruthy(); + await removePluginDirectory(testDirPath); + expect(fse.existsSync(testDirPath)).toBeFalsy(); }); }); + describe('Removes System Link', () => { it('removes system link from @backstage', async () => { - const scopedDir = paths.resolveTargetRoot('node_modules', '@backstage'); + const symLink = `plugin-${testPluginName}`; const testSymLinkPath = path.join( - scopedDir, - `plugin-${testPluginName}`, + '/', + 'node_modules', + '@backstage', + symLink, ); - try { - mkTestPluginDir(testDirPath); - fse.ensureSymlinkSync(testSymLinkPath, testDirPath); + const mockedTestDirPath = path.join('/', 'plugins', testPluginName); - await removeSymLink(testSymLinkPath); - expect(fse.existsSync(testSymLinkPath)).toBeFalsy(); - } finally { - if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); - if (fse.existsSync(testSymLinkPath)) fse.removeSync(testSymLinkPath); - } + mockFs({ + '/plugins': { + [testPluginName]: {}, + }, + '/node_modules': { + '@backstage': { + [symLink]: mockFs.symlink({ + path: mockedTestDirPath, + }), + }, + }, + }); + + expect(fse.existsSync(testSymLinkPath)).toBeTruthy(); + await removeSymLink(testSymLinkPath); + expect(fse.existsSync(testSymLinkPath)).toBeFalsy(); }); }); }); }); - -afterAll(() => { - // Remove temporary directory - fse.removeSync(tempDir); -}); diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index 1395384022..81d8322ffa 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -20,7 +20,6 @@ import inquirer, { Answers, Question } from 'inquirer'; import { getCodeownersFilePath } from '../../lib/codeowners'; import { paths } from '../../lib/paths'; import { Task } from '../../lib/tasks'; -// import os from 'os'; const BACKSTAGE = '@backstage'; @@ -63,7 +62,7 @@ export const removePluginDirectory = async (destination: string) => { export const removeSymLink = async (destination: string) => { await Task.forItem('removing', 'symbolic link', async () => { - const symLinkExists = fse.pathExists(destination); + const symLinkExists = await fse.pathExists(destination); if (symLinkExists) { try { await fse.remove(destination); @@ -190,7 +189,7 @@ export default async () => { return chalk.red('Please enter an ID for the plugin'); } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { return chalk.red( - 'Plugin IDs must be kehbab-cased and contain only letters, digits and dashes.', + 'Plugin IDs must be kebab-cased and contain only letters, digits and dashes.', ); } return true; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e278164f4c..28e46bbd1a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -27,18 +27,12 @@ const main = (argv: string[]) => { program.on('command:*', () => { console.log(); - console.log( - chalk.red(`Invalid command: ${chalk.cyan(program.args.join(' '))}`), - ); - console.log(chalk.red('See --help for a list of available commands.')); + console.log(chalk.red(`Invalid command: ${program.args.join(' ')}`)); console.log(); + program.outputHelp(); process.exit(1); }); - if (!process.argv.slice(2).length) { - program.outputHelp(chalk.yellow); - } - program.parse(argv); }; diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index 2566460494..2e7847fdb1 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -64,6 +64,7 @@ export const makeConfigs = async ( entryFileNames: 'index.cjs.js', chunkFileNames: 'cjs/[name]-[hash].js', format: 'commonjs', + sourcemap: true, }); } if (options.outputs.has(Output.esm)) { @@ -72,6 +73,7 @@ export const makeConfigs = async ( entryFileNames: 'index.esm.js', chunkFileNames: 'esm/[name]-[hash].js', format: 'module', + sourcemap: true, }); // Assume we're building for the browser if ESM output is included mainFields.unshift('browser'); diff --git a/packages/cli/src/lib/bundler/backend.ts b/packages/cli/src/lib/bundler/backend.ts index e3cc8af8d1..9633c1b963 100644 --- a/packages/cli/src/lib/bundler/backend.ts +++ b/packages/cli/src/lib/bundler/backend.ts @@ -17,13 +17,9 @@ import webpack from 'webpack'; import { createBackendConfig } from './config'; import { resolveBundlingPaths } from './paths'; -import { ServeOptions } from './types'; +import { BackendServeOptions } from './types'; -export async function serveBackend( - options: ServeOptions & { - inspectEnabled: boolean; - }, -) { +export async function serveBackend(options: BackendServeOptions) { const paths = resolveBundlingPaths(options); const config = await createBackendConfig(paths, { ...options, diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index 8746c807e7..30fef2e8b7 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -33,14 +33,14 @@ const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; export async function buildBundle(options: BuildOptions) { - const { statsJsonEnabled } = options; + const { statsJsonEnabled, schema: configSchema } = options; const paths = resolveBundlingPaths(options); const config = await createConfig(paths, { ...options, checksEnabled: false, isDev: false, - baseUrl: resolveBaseUrl(options.config), + baseUrl: resolveBaseUrl(options.frontendConfig), }); const compiler = webpack(config); @@ -56,6 +56,14 @@ export async function buildBundle(options: BuildOptions) { }); } + if (configSchema) { + await fs.writeJson( + resolvePath(paths.targetDist, '.config-schema.json'), + configSchema.serialize(), + { spaces: 2 }, + ); + } + const { stats } = await build(compiler, isCi).catch(error => { console.log(chalk.red('Failed to compile.\n')); throw new Error(`Failed to compile.\n${error.message || error}`); diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 118cb1b40c..9e150e0ccb 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -74,11 +74,11 @@ export async function createConfig( paths: BundlingPaths, options: BundlingOptions, ): Promise { - const { checksEnabled, isDev } = options; + const { checksEnabled, isDev, frontendConfig } = options; const { plugins, loaders } = transforms(options); - const baseUrl = options.config.getString('app.baseUrl'); + const baseUrl = frontendConfig.getString('app.baseUrl'); const validBaseUrl = new URL(baseUrl); if (checksEnabled) { @@ -99,7 +99,7 @@ export async function createConfig( plugins.push( new webpack.EnvironmentPlugin({ - APP_CONFIG: options.appConfigs, + APP_CONFIG: options.frontendAppConfigs, }), ); @@ -109,8 +109,11 @@ export async function createConfig( templateParameters: { publicPath: validBaseUrl.pathname.replace(/\/$/, ''), app: { - title: options.config.getString('app.title'), + title: frontendConfig.getString('app.title'), baseUrl: validBaseUrl.href, + googleAnalyticsTrackingId: frontendConfig.getOptionalString( + 'app.googleAnalyticsTrackingId', + ), }, }, }), @@ -209,6 +212,7 @@ export async function createBackendConfig( ], target: 'node' as const, node: { + /* eslint-disable-next-line no-restricted-syntax */ __dirname: true, __filename: true, global: true, diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts index acd6a937d9..665eb10b21 100644 --- a/packages/cli/src/lib/bundler/optimization.ts +++ b/packages/cli/src/lib/bundler/optimization.ts @@ -15,7 +15,9 @@ */ import { Options } from 'webpack'; +import TerserPlugin from 'terser-webpack-plugin'; import { BundlingOptions } from './types'; +import { isParallelDefault } from '../parallel'; export const optimization = ( options: BundlingOptions, @@ -24,6 +26,16 @@ export const optimization = ( return { minimize: !isDev, + // Only configure when parallel is explicitly overriden from the default + ...(!isParallelDefault(options.parallel) + ? { + minimizer: [ + new TerserPlugin({ + parallel: options.parallel, + }), + ], + } + : {}), runtimeChunk: 'single', splitChunks: { automaticNameDelimiter: '-', diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index e1fb560e22..d198f5507c 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -23,9 +23,14 @@ import { ServeOptions } from './types'; import { resolveBundlingPaths } from './paths'; export async function serveBundle(options: ServeOptions) { - const url = resolveBaseUrl(options.config); + const url = resolveBaseUrl(options.frontendConfig); - const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80); + const host = + options.frontendConfig.getOptionalString('app.listen.host') || url.hostname; + const port = + options.frontendConfig.getOptionalNumber('app.listen.port') || + Number(url.port) || + (url.protocol === 'https:' ? 443 : 80); const paths = resolveBundlingPaths(options); const pkgPath = paths.targetPackageJson; @@ -50,7 +55,7 @@ export async function serveBundle(options: ServeOptions) { clientLogLevel: 'warning', stats: 'errors-warnings', https: url.protocol === 'https:', - host: url.hostname, + host, port, proxy: pkg.proxy, }); diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 03d68d9bb8..71343a1761 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -16,27 +16,40 @@ import { AppConfig, Config } from '@backstage/config'; import { BundlingPathsOptions } from './paths'; +import { ParallelOption } from '../parallel'; +import { ConfigSchema } from '@backstage/config-loader'; export type BundlingOptions = { checksEnabled: boolean; isDev: boolean; - config: Config; - appConfigs: AppConfig[]; + frontendConfig: Config; + frontendAppConfigs: AppConfig[]; baseUrl: URL; -}; - -export type BackendBundlingOptions = Omit & { - inspectEnabled: boolean; + parallel?: ParallelOption; }; export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; - config: Config; - appConfigs: AppConfig[]; + frontendConfig: Config; + frontendAppConfigs: AppConfig[]; }; export type BuildOptions = BundlingPathsOptions & { statsJsonEnabled: boolean; - config: Config; - appConfigs: AppConfig[]; + parallel?: ParallelOption; + schema?: ConfigSchema; + frontendConfig: Config; + frontendAppConfigs: AppConfig[]; +}; + +export type BackendBundlingOptions = { + checksEnabled: boolean; + isDev: boolean; + parallel?: ParallelOption; + inspectEnabled: boolean; +}; + +export type BackendServeOptions = BundlingPathsOptions & { + checksEnabled: boolean; + inspectEnabled: boolean; }; diff --git a/packages/cli/src/lib/codeowners/codeowners.ts b/packages/cli/src/lib/codeowners/codeowners.ts index 91b0e19782..aa24f91aac 100644 --- a/packages/cli/src/lib/codeowners/codeowners.ts +++ b/packages/cli/src/lib/codeowners/codeowners.ts @@ -20,7 +20,7 @@ import path from 'path'; const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/; const USER_ID_RE = /^@[-\w]+$/; const EMAIL_RE = /^[^@]+@[-.\w]+\.[-\w]+$/i; -const DEFAULT_OWNER = '@spotify/backstage-core'; +const DEFAULT_OWNER = '@backstage/maintainers'; type CodeownersEntry = { ownedPath: string; diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts new file mode 100644 index 0000000000..08a8193818 --- /dev/null +++ b/packages/cli/src/lib/config.ts @@ -0,0 +1,63 @@ +/* + * 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 { loadConfig, loadConfigSchema } from '@backstage/config-loader'; +import { ConfigReader } from '@backstage/config'; +import { paths } from './paths'; + +export async function loadCliConfig(configArgs: string[]) { + const configPaths = configArgs.map(arg => paths.resolveTarget(arg)); + + // Consider all packages in the monorepo when loading in config + const LernaProject = require('@lerna/project'); + const project = new LernaProject(paths.targetDir); + const packages = await project.getPackages(); + const localPackageNames = packages.map((p: any) => p.name); + const schema = await loadConfigSchema({ + dependencies: localPackageNames, + }); + + const appConfigs = await loadConfig({ + env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production', + configRoot: paths.targetRoot, + configPaths, + }); + + console.log( + `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`, + ); + + try { + const frontendAppConfigs = schema.process(appConfigs, { + visiblity: ['frontend'], + }); + const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs); + + return { + schema, + appConfigs, + frontendConfig, + frontendAppConfigs, + }; + } catch (error) { + const maybeSchemaError = error as Error & { messages?: string[] }; + if (maybeSchemaError.messages) { + const messages = maybeSchemaError.messages.join('\n '); + throw new Error(`Configuration does not match schema\n\n ${messages}`); + } + throw error; + } +} diff --git a/packages/cli/src/lib/diff/handlers.ts b/packages/cli/src/lib/diff/handlers.ts index 40e3d4c587..a210986b2a 100644 --- a/packages/cli/src/lib/diff/handlers.ts +++ b/packages/cli/src/lib/diff/handlers.ts @@ -61,7 +61,7 @@ class PackageJsonHandler { await this.syncField('main:src'); } await this.syncField('types'); - await this.syncField('files'); + await this.syncFiles(); await this.syncScripts(); await this.syncPublishConfig(); await this.syncDependencies('dependencies'); @@ -105,6 +105,15 @@ class PackageJsonHandler { } } + private async syncFiles() { + if (typeof this.targetPkg.configSchema === 'string') { + const files = [...this.pkg.files, this.targetPkg.configSchema]; + await this.syncField('files', { files }); + } else { + await this.syncField('files'); + } + } + private async syncScripts() { const pkgScripts = this.pkg.scripts; const targetScripts = (this.targetPkg.scripts = diff --git a/packages/cli/src/lib/diff/read.ts b/packages/cli/src/lib/diff/read.ts index 75e1b86d24..d37dfe4b03 100644 --- a/packages/cli/src/lib/diff/read.ts +++ b/packages/cli/src/lib/diff/read.ts @@ -24,6 +24,7 @@ import handlebars from 'handlebars'; import recursiveReadDir from 'recursive-readdir'; import { paths } from '../paths'; import { FileDiff } from './types'; +import { packageVersions } from '../../lib/version'; export type TemplatedFile = { path: string; @@ -40,7 +41,16 @@ async function readTemplateFile( return contents; } - return handlebars.compile(contents)(templateVars); + return handlebars.compile(contents)(templateVars, { + helpers: { + version(name: keyof typeof packageVersions) { + if (name in packageVersions) { + return packageVersions[name]; + } + throw new Error(`No version available for package ${name}`); + }, + }, + }); } async function readTemplate( diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index 564317e46a..8653e66d37 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -20,10 +20,19 @@ import { resolve as resolvePath, relative as relativePath, } from 'path'; +import { tmpdir } from 'os'; +import tar, { CreateOptions } from 'tar'; import { paths } from '../paths'; import { run } from '../run'; -import tar, { CreateOptions } from 'tar'; -import { tmpdir } from 'os'; +import { packageVersions } from '../version'; +import { ParallelOption } from '../parallel'; + +// These packages aren't safe to pack in parallel since the CLI depends on them +const UNSAFE_PACKAGES = [ + ...Object.keys(packageVersions), + '@backstage/cli-common', + '@backstage/config-loader', +]; type LernaPackage = { name: string; @@ -58,6 +67,11 @@ type Options = { */ buildDependencies?: boolean; + /** + * Enable (true/false) or control amount of (number) parallelism in some build steps. + */ + parallel?: ParallelOption; + /** * If set, creates a skeleton tarball that contains all package.json files * with the same structure as the workspace dir. @@ -85,7 +99,12 @@ export async function createDistWorkspace( if (options.buildDependencies) { const scopeArgs = targets.flatMap(target => ['--scope', target.name]); - await run('yarn', ['lerna', 'run', ...scopeArgs, 'build'], { + const lernaArgs = + options.parallel && Number.isInteger(options.parallel) + ? ['--concurrency', options.parallel.toString()] + : []; + + await run('yarn', ['lerna', ...lernaArgs, 'run', ...scopeArgs, 'build'], { cwd: paths.targetRoot, }); } @@ -124,51 +143,68 @@ async function moveToDistWorkspace( workspaceDir: string, localPackages: LernaPackage[], ): Promise { + async function pack(target: LernaPackage, archive: string) { + console.log(`Repacking ${target.name} into dist workspace`); + const archivePath = resolvePath(workspaceDir, archive); + + await run('yarn', ['pack', '--filename', archivePath], { + cwd: target.location, + }); + // TODO(Rugvip): yarn pack doesn't call postpack, once the bug is fixed this can be removed + if (target.scripts.postpack) { + await run('yarn', ['postpack'], { cwd: target.location }); + } + + const outputDir = relativePath(paths.targetRoot, target.location); + const absoluteOutputPath = resolvePath(workspaceDir, outputDir); + await fs.ensureDir(absoluteOutputPath); + + await tar.extract({ + file: archivePath, + cwd: absoluteOutputPath, + strip: 1, + }); + await fs.remove(archivePath); + + // We remove the dependencies from package.json of packages that are marked + // as bundled, so that yarn doesn't try to install them. + if (target.get('bundled')) { + const pkgJson = await fs.readJson( + resolvePath(absoluteOutputPath, 'package.json'), + ); + delete pkgJson.dependencies; + delete pkgJson.devDependencies; + delete pkgJson.peerDependencies; + delete pkgJson.optionalDependencies; + + await fs.writeJson( + resolvePath(absoluteOutputPath, 'package.json'), + pkgJson, + { + spaces: 2, + }, + ); + } + } + + const unsafePackages = localPackages.filter(p => + UNSAFE_PACKAGES.includes(p.name), + ); + const safePackages = localPackages.filter( + p => !UNSAFE_PACKAGES.includes(p.name), + ); + + // The unsafe package are packed first one by one in order to avoid race conditions + // where the CLI is being executed with broken dependencies. + for (const target of unsafePackages) { + await pack(target, `temp-package.tgz`); + } + + // Repacking in parallel is much faster and safe for all packages outside of the Backstage repo await Promise.all( - localPackages.map(async (target, index) => { - console.log(`Repacking ${target.name} into dist workspace`); - const archive = `temp-package-${index}.tgz`; - const archivePath = resolvePath(workspaceDir, archive); - - await run('yarn', ['pack', '--filename', archivePath], { - cwd: target.location, - }); - // TODO(Rugvip): yarn pack doesn't call postpack, once the bug is fixed this can be removed - if (target.scripts.postpack) { - await run('yarn', ['postpack'], { cwd: target.location }); - } - - const outputDir = relativePath(paths.targetRoot, target.location); - const absoluteOutputPath = resolvePath(workspaceDir, outputDir); - await fs.ensureDir(absoluteOutputPath); - - await tar.extract({ - file: archivePath, - cwd: absoluteOutputPath, - strip: 1, - }); - await fs.remove(archivePath); - - // We remove the dependencies from package.json of packages that are marked - // as bundled, so that yarn doesn't try to install them. - if (target.get('bundled')) { - const pkgJson = await fs.readJson( - resolvePath(absoluteOutputPath, 'package.json'), - ); - delete pkgJson.dependencies; - delete pkgJson.devDependencies; - delete pkgJson.peerDependencies; - delete pkgJson.optionalDependencies; - - await fs.writeJson( - resolvePath(absoluteOutputPath, 'package.json'), - pkgJson, - { - spaces: 2, - }, - ); - } - }), + safePackages.map(async (target, index) => + pack(target, `temp-package-${index}.tgz`), + ), ); } diff --git a/packages/cli/src/lib/parallel.test.ts b/packages/cli/src/lib/parallel.test.ts new file mode 100644 index 0000000000..8047774a6e --- /dev/null +++ b/packages/cli/src/lib/parallel.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { isParallelDefault, parseParallel } from './parallel'; + +describe('parallel', () => { + describe('parseParallel', () => { + it('coerces "false" string to boolean', () => { + expect(parseParallel('false')).toBeFalsy(); + }); + + it('coerces "true" to boolean', () => { + expect(parseParallel('true')).toBeTruthy(); + }); + + it('coerces number string to number', () => { + expect(parseParallel('2')).toBe(2); + }); + it.each([[true], [false], [2]])('returns itself for %p', value => { + expect(parseParallel(value as any)).toEqual(value); + }); + + it.each([[undefined], [null]])('returns true for %p', value => { + expect(parseParallel(value as any)).toBe(true); + }); + + it.each([['on'], [2.5], ['2.5']])('throws error for %p', value => { + expect(() => parseParallel(value as any)).toThrowError( + `Parallel option value '${value}' is not a boolean or integer`, + ); + }); + }); + + describe('isParallelDefault', () => { + it('returns true if default value', () => { + expect(isParallelDefault(undefined)).toBeTruthy(); + expect(isParallelDefault(true)).toBeTruthy(); + }); + + it('returns false if not default value', () => { + expect(isParallelDefault(false)).toBeFalsy(); + expect(isParallelDefault(2)).toBeFalsy(); + expect(isParallelDefault('true' as any)).toBeFalsy(); + }); + }); +}); diff --git a/packages/cli/src/lib/parallel.ts b/packages/cli/src/lib/parallel.ts new file mode 100644 index 0000000000..b6926115aa --- /dev/null +++ b/packages/cli/src/lib/parallel.ts @@ -0,0 +1,47 @@ +/* + * 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 PARALLEL_ENV_VAR = 'BACKSTAGE_CLI_BUILD_PARALLEL'; + +export type ParallelOption = boolean | number | undefined; + +export function isParallelDefault(parallel: ParallelOption) { + return parallel === undefined || parallel === true; +} + +export function parseParallel( + parallel: boolean | string | number | undefined, +): ParallelOption { + if (parallel === undefined || parallel === null) { + return true; + } else if (typeof parallel === 'boolean') { + return parallel; + } else if (typeof parallel === 'number' && Number.isInteger(parallel)) { + return parallel; + } else if (typeof parallel === 'string') { + if (parallel === 'true') { + return true; + } else if (parallel === 'false') { + return false; + } else if (Number.isInteger(parseFloat(parallel.toString()))) { + return Number(parallel); + } + } + + throw Error( + `Parallel option value '${parallel}' is not a boolean or integer`, + ); +} diff --git a/packages/cli/src/lib/paths.ts b/packages/cli/src/lib/paths.ts index 06c8f2ab38..a17034344d 100644 --- a/packages/cli/src/lib/paths.ts +++ b/packages/cli/src/lib/paths.ts @@ -16,4 +16,5 @@ import { findPaths } from '@backstage/cli-common'; +/* eslint-disable-next-line no-restricted-syntax */ export const paths = findPaths(__dirname); diff --git a/packages/cli/src/lib/tasks.test.ts b/packages/cli/src/lib/tasks.test.ts index 871d5a4d37..88d8050290 100644 --- a/packages/cli/src/lib/tasks.test.ts +++ b/packages/cli/src/lib/tasks.test.ts @@ -15,39 +15,51 @@ */ import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { resolve as resolvePath } from 'path'; -import os from 'os'; -import del from 'del'; import { templatingTask } from './tasks'; describe('templatingTask', () => { + afterEach(() => { + mockFs.restore(); + }); + it('should template a directory with mix of regular files and templates', async () => { - // Set up a testing template directory - const tmplDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-')); - await fs.ensureDir(resolvePath(tmplDir, 'sub')); - await fs.writeFile(resolvePath(tmplDir, 'test.txt'), 'testing'); - await fs.writeFile( - resolvePath(tmplDir, 'sub/version.txt.hbs'), - 'version: {{version}}', + // Testing template directory + const tmplDir = 'test-tmpl'; + + // Temporary dest dir to write the template to + const destDir = 'test-dest'; + + // Files content + const testFileContent = 'testing'; + const testVersionFileContent = + "version: {{pluginVersion}} {{version 'mock-pkg'}}"; + + mockFs({ + [tmplDir]: { + sub: { + 'version.txt.hbs': testVersionFileContent, + }, + 'test.txt': testFileContent, + }, + [destDir]: {}, + }); + + await templatingTask( + tmplDir, + destDir, + { + pluginVersion: '0.0.0', + }, + { 'mock-pkg': '0.1.2' }, ); - // Set up a temporary dest dir to write the template to - const destDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-')); - - try { - await templatingTask(tmplDir, destDir, { - version: '0.0.0', - }); - - await expect( - fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'), - ).resolves.toBe('testing'); - await expect( - fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'), - ).resolves.toBe('version: 0.0.0'); - } finally { - await del(tmplDir, { force: true }); - await del(destDir, { force: true }); - } + await expect( + fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'), + ).resolves.toBe(testFileContent); + await expect( + fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'), + ).resolves.toBe('version: 0.0.0 0.1.2'); }); }); diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index 0753301b78..4b92c6b9cb 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -20,6 +20,7 @@ import handlebars from 'handlebars'; import ora from 'ora'; import { basename, dirname } from 'path'; import recursive from 'recursive-readdir'; +import { paths } from './paths'; const TASK_NAME_MAX_LENGTH = 14; @@ -68,10 +69,12 @@ export async function templatingTask( templateDir: string, destinationDir: string, context: any, + versions: { [name: string]: string }, ) { const files = await recursive(templateDir).catch(error => { throw new Error(`Failed to read template directory: ${error.message}`); }); + const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json')); for (const file of files) { const destinationFile = file.replace(templateDir, destinationDir); @@ -83,7 +86,19 @@ export async function templatingTask( const template = await fs.readFile(file); const compiled = handlebars.compile(template.toString()); - const contents = compiled({ name: basename(destination), ...context }); + const contents = compiled( + { name: basename(destination), ...context }, + { + helpers: { + version(name: string) { + if (versions[name]) { + return versions[name]; + } + throw new Error(`No version available for package ${name}`); + }, + }, + }, + ); await fs.writeFile(destination, contents).catch(error => { throw new Error( @@ -92,6 +107,10 @@ export async function templatingTask( }); }); } else { + if (isMonoRepo && file.match('tsconfig.json')) { + continue; + } + await Task.forItem('copying', basename(file), async () => { await fs.copyFile(file, destinationFile).catch(error => { const destination = destinationFile; diff --git a/packages/cli/src/lib/version.ts b/packages/cli/src/lib/version.ts index 24734b87cc..b2b793717d 100644 --- a/packages/cli/src/lib/version.ts +++ b/packages/cli/src/lib/version.ts @@ -17,6 +17,38 @@ import fs from 'fs-extra'; import { paths } from './paths'; +/* eslint-disable import/no-extraneous-dependencies,monorepo/no-internal-import */ +/* +This is a list of all packages used by the templates. If dependencies are added or removed, +this list should be updated as well. + +The list, and the accompanying devDependencies entries, are here to ensure correct versioning +and bumping of this package. Without this list the version would not be bumped unless we +manually trigger a release. + +This does not create an actual dependency on these packages and does not bring in any code. +Rollup will extract the value of the version field in each package at build time without +leaving any imports in place. +*/ + +import { version as backendCommon } from '@backstage/backend-common/package.json'; +import { version as cli } from '@backstage/cli/package.json'; +import { version as config } from '@backstage/config/package.json'; +import { version as core } from '@backstage/core/package.json'; +import { version as devUtils } from '@backstage/dev-utils/package.json'; +import { version as testUtils } from '@backstage/test-utils/package.json'; +import { version as theme } from '@backstage/theme/package.json'; + +export const packageVersions = { + '@backstage/backend-common': backendCommon, + '@backstage/cli': cli, + '@backstage/config': config, + '@backstage/core': core, + '@backstage/dev-utils': devUtils, + '@backstage/test-utils': testUtils, + '@backstage/theme': theme, +}; + export function findVersion() { const pkgContent = fs.readFileSync(paths.resolveOwn('package.json'), 'utf8'); return JSON.parse(pkgContent).version; diff --git a/packages/cli/src/types.d.ts b/packages/cli/src/types.d.ts index 94136f1dbc..828819ad17 100644 --- a/packages/cli/src/types.d.ts +++ b/packages/cli/src/types.d.ts @@ -29,3 +29,5 @@ declare module '@svgr/rollup' { } declare module '@rollup/plugin-yaml'; + +declare module 'terser-webpack-plugin'; diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index 40e612609a..b762bb9645 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -1,6 +1,6 @@ { "name": "{{name}}", - "version": "{{version}}", + "version": "{{pluginVersion}}", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -23,20 +23,20 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^{{version}}", - "@backstage/config": "^{{version}}", + "@backstage/backend-common": "^{{version '@backstage/backend-common'}}", + "@backstage/config": "^{{version '@backstage/config'}}", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", "winston": "^3.2.1", - "node-fetch": "^2.6.1", + "cross-fetch": "^3.0.6", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^{{version}}", + "@backstage/cli": "^{{version '@backstage/cli'}}", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", - "msw": "^0.20.5" + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/packages/cli/templates/default-backend-plugin/src/setupTests.ts b/packages/cli/templates/default-backend-plugin/src/setupTests.ts index a5907fd52f..ba33cf996b 100644 --- a/packages/cli/templates/default-backend-plugin/src/setupTests.ts +++ b/packages/cli/templates/default-backend-plugin/src/setupTests.ts @@ -15,4 +15,3 @@ */ export {}; -global.fetch = require('node-fetch'); diff --git a/packages/cli/templates/default-backend-plugin/tsconfig.json b/packages/cli/templates/default-backend-plugin/tsconfig.json new file mode 100644 index 0000000000..d77f0fe3b4 --- /dev/null +++ b/packages/cli/templates/default-backend-plugin/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": [ + "src", + "dev", + "migrations" + ], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index e024884547..a3f924408a 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -1,6 +1,6 @@ { "name": "{{name}}", - "version": "{{version}}", + "version": "{{pluginVersion}}", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -24,8 +24,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^{{backstageVersion}}", - "@backstage/theme": "^{{backstageVersion}}", + "@backstage/core": "^{{version '@backstage/core'}}", + "@backstage/theme": "^{{version '@backstage/theme'}}", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -34,15 +34,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^{{backstageVersion}}", - "@backstage/dev-utils": "^{{backstageVersion}}", + "@backstage/cli": "^{{version '@backstage/cli'}}", + "@backstage/dev-utils": "^{{version '@backstage/dev-utils'}}", + "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs index fdb39444d8..e805900f36 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs @@ -5,18 +5,13 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; describe('ExampleComponent', () => { const server = setupServer(); - // Enable API mocking before tests. - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) - - // Reset any runtime request handlers we may add during the tests. - afterEach(() => server.resetHandlers()) - - // Disable API mocking after the tests are done. - afterAll(() => server.close()) + // Enable sane handlers for network requests + msw.setupDefaultHandlers(server); // setup mock response beforeEach(() => { diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs index 79adaefaa8..e08f1650d5 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs @@ -4,7 +4,6 @@ import { InfoCard, Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -13,7 +12,7 @@ import { import ExampleFetchComponent from '../ExampleFetchComponent'; const ExampleComponent: FC<{}> = () => ( - +

@@ -37,5 +36,5 @@ const ExampleComponent: FC<{}> = () => ( ); - + export default ExampleComponent; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs index ca1990b4bc..81e1b4be09 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs @@ -3,18 +3,13 @@ import { render } from '@testing-library/react'; import ExampleFetchComponent from './ExampleFetchComponent'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; describe('ExampleFetchComponent', () => { const server = setupServer(); - // Enable API mocking before tests. - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) - - // Reset any runtime request handlers we may add during the tests. - afterEach(() => server.resetHandlers()) - - // Disable API mocking after the tests are done. - afterAll(() => server.close()) - + // Enable sane handlers for network requests + msw.setupDefaultHandlers(server); + // setup mock response beforeEach(() => { server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({})))) diff --git a/packages/cli/templates/default-plugin/src/setupTests.ts b/packages/cli/templates/default-plugin/src/setupTests.ts index cc559f672e..292b0cc471 100644 --- a/packages/cli/templates/default-plugin/src/setupTests.ts +++ b/packages/cli/templates/default-plugin/src/setupTests.ts @@ -1,2 +1,2 @@ import '@testing-library/jest-dom'; -global.fetch = require('node-fetch'); +import 'cross-fetch/polyfill' diff --git a/packages/cli/templates/default-plugin/tsconfig.json b/packages/cli/templates/default-plugin/tsconfig.json new file mode 100644 index 0000000000..b61e496175 --- /dev/null +++ b/packages/cli/templates/default-plugin/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@backstage/cli/config/tsconfig.json", + "include": [ + "src", + "dev" + ], + "exclude": ["node_modules"], + "compilerOptions": { + "outDir": "dist-types", + "rootDir": "." + } +} diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md new file mode 100644 index 0000000000..729dc81206 --- /dev/null +++ b/packages/config-loader/CHANGELOG.md @@ -0,0 +1,47 @@ +# @backstage/config-loader + +## 0.3.0 + +### Minor Changes + +- 1722cb53c: Added support for loading and validating configuration schemas, as well as declaring config visibility through schemas. + + The new `loadConfigSchema` function exported by `@backstage/config-loader` allows for the collection and merging of configuration schemas from all nearby dependencies of the project. + + A configuration schema is declared using the `https://backstage.io/schema/config-v1` JSON Schema meta schema, which is based on draft07. The only difference to the draft07 schema is the custom `visibility` keyword, which is used to indicate whether the given config value should be visible in the frontend or not. The possible values are `frontend`, `backend`, and `secret`, where `backend` is the default. A visibility of `secret` has the same scope at runtime, but it will be treated with more care in certain contexts, and defining both `frontend` and `secret` for the same value in two different schemas will result in an error during schema merging. + + Packages that wish to contribute configuration schema should declare it in a root `"configSchema"` field in `package.json`. The field can either contain an inlined JSON schema, or a relative path to a schema file. Schema files can be in either `.json` or `.d.ts` format. + + TypeScript configuration schema files should export a single `Config` type, for example: + + ```ts + export interface Config { + app: { + /** + * Frontend root URL + * @visibility frontend + */ + baseUrl: string; + }; + } + ``` + +## 0.2.0 + +### Minor Changes + +- 8c2b76e45: **BREAKING CHANGE** + + The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed. + Instead, the CLI and backend process now accept one or more `--config` flags to load config files. + + Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded. + If passing any `--config ` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one. + + The old behaviour of for example `APP_ENV=development` can be replicated using the following flags: + + ```bash + --config ../../app-config.yaml --config ../../app-config.development.yaml + ``` + +- ce5512bc0: Added support for new shorthand when defining secrets, where `$env: ENV` can be used instead of `$secret: { env: ENV }` etc. diff --git a/packages/config-loader/README.md b/packages/config-loader/README.md index 595241e0d5..261394a6a5 100644 --- a/packages/config-loader/README.md +++ b/packages/config-loader/README.md @@ -8,5 +8,5 @@ Do not install this package directly, it is an internal package used by [@backst ## Documentation -- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 6a17eba76c..1584a61df5 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.1.1-alpha.24", + "version": "0.3.0", "private": false, "publishConfig": { "access": "public", @@ -12,7 +12,7 @@ "homepage": "https://backstage.io", "repository": { "type": "git", - "url": "https://github.com/spotify/backstage", + "url": "https://github.com/backstage/backstage", "directory": "packages/config-loader" }, "keywords": [ @@ -30,16 +30,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.24", + "@backstage/cli-common": "^0.1.1", + "@backstage/config": "^0.1.1", + "ajv": "^6.12.5", "fs-extra": "^9.0.0", + "json-schema": "^0.2.5", + "json-schema-merge-allof": "^0.7.0", + "typescript-json-schema": "^0.43.0", "yaml": "^1.9.2", - "yup": "^0.29.1" + "yup": "^0.29.3" }, "devDependencies": { "@types/jest": "^26.0.7", + "@types/json-schema": "^7.0.6", + "@types/json-schema-merge-allof": "^0.6.0", "@types/mock-fs": "^4.10.0", "@types/node": "^12.0.0", - "@types/yup": "^0.28.2", + "@types/yup": "^0.29.8", "mock-fs": "^4.13.0" }, "files": [ diff --git a/packages/config-loader/src/index.ts b/packages/config-loader/src/index.ts index 02db134fe5..9ad54c5f18 100644 --- a/packages/config-loader/src/index.ts +++ b/packages/config-loader/src/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ -export { readEnvConfig } from './lib'; +export { readEnvConfig, loadConfigSchema } from './lib'; +export type { ConfigSchema, ConfigVisibility } from './lib'; export { loadConfig } from './loader'; export type { LoadConfigOptions } from './loader'; diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts index 40252b9cec..ceb7c34222 100644 --- a/packages/config-loader/src/lib/index.ts +++ b/packages/config-loader/src/lib/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export { resolveStaticConfig } from './resolver'; export { readConfigFile } from './reader'; export { readEnvConfig } from './env'; export { readSecret } from './secrets'; +export * from './schema'; diff --git a/packages/config-loader/src/lib/reader.test.ts b/packages/config-loader/src/lib/reader.test.ts index 6ccceef60d..a0a8495714 100644 --- a/packages/config-loader/src/lib/reader.test.ts +++ b/packages/config-loader/src/lib/reader.test.ts @@ -28,7 +28,6 @@ function memoryFiles(files: { [path: string]: string }) { const mockContext: ReaderContext = { env: {}, - skip: () => false, readFile: jest.fn(), readSecret: jest.fn(), }; @@ -179,22 +178,4 @@ describe('readConfigFile', () => { await expect(config).rejects.toThrow('Invalid secret at .app: NOPE'); }); - - it('should omit skipped values', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { title: skip, name: include }', - }); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - skip: (path: string) => path === '.app.title', - readSecret: jest.fn() as ReadSecretFunc, - }); - - await expect(config).resolves.toEqual({ - context: 'app-config.yaml', - data: { app: { name: 'include' } }, - }); - }); }); diff --git a/packages/config-loader/src/lib/reader.ts b/packages/config-loader/src/lib/reader.ts index 8eadae0fe0..9eba58be97 100644 --- a/packages/config-loader/src/lib/reader.ts +++ b/packages/config-loader/src/lib/reader.ts @@ -37,10 +37,6 @@ export async function readConfigFile( obj: JsonValue, path: string, ): Promise { - if (ctx.skip(path)) { - return undefined; - } - if (typeof obj !== 'object') { return obj; } else if (obj === null) { diff --git a/packages/config-loader/src/lib/resolver.test.ts b/packages/config-loader/src/lib/resolver.test.ts deleted file mode 100644 index 9fd71f2486..0000000000 --- a/packages/config-loader/src/lib/resolver.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import mockFs from 'mock-fs'; -import { resolveStaticConfig } from './resolver'; - -function normalizePaths(paths: string[]) { - return paths.map(p => - p - .replace(/^[a-z]:/i, '') - .split('\\') - .join('/'), - ); -} - -describe('resolveStaticConfig', () => { - afterEach(() => { - mockFs.restore(); - }); - - it('should resolve no files for empty roots', async () => { - mockFs({}); - const resolved = await resolveStaticConfig({ - env: 'development', - rootPaths: [], - }); - - expect(normalizePaths(resolved)).toEqual([]); - }); - - it('should resolve a single app-config', async () => { - mockFs({ '/repo/app-config.yaml': '' }); - const resolved = await resolveStaticConfig({ - env: 'development', - rootPaths: ['/repo'], - }); - - expect(normalizePaths(resolved)).toEqual(['/repo/app-config.yaml']); - }); - - it('should resolve a app-configs in different directories', async () => { - mockFs({ - '/repo/app-config.yaml': '', - '/repo/packages/a/app-config.yaml': '', - }); - const resolved = await resolveStaticConfig({ - env: 'development', - rootPaths: [ - '/repo', - '/other-repo', - '/repo/packages/a', - '/repo/packages/b', - ], - }); - - expect(normalizePaths(resolved)).toEqual([ - '/repo/app-config.yaml', - '/repo/packages/a/app-config.yaml', - ]); - }); - - it('should resolve env and local configs', async () => { - mockFs({ - '/repo/app-config.yaml': '', - '/repo/app-config.local.yaml': '', - '/repo/app-config.production.yaml': '', - '/repo/app-config.production.local.yaml': '', - '/repo/app-config.development.local.yaml': '', - '/repo/packages/a/app-config.development.yaml': '', - '/repo/packages/a/app-config.local.yaml': '', - }); - const resolved = await resolveStaticConfig({ - env: 'development', - rootPaths: ['/repo', '/repo/packages/a'], - }); - - expect(normalizePaths(resolved)).toEqual([ - '/repo/app-config.yaml', - '/repo/app-config.local.yaml', - '/repo/app-config.development.local.yaml', - '/repo/packages/a/app-config.local.yaml', - '/repo/packages/a/app-config.development.yaml', - ]); - }); - - it('resolves suffixed configs in the correct order', async () => { - mockFs({ - '/repo/app-config.yaml': '', - '/repo/app-config.local.yaml': '', - '/repo/app-config.production.yaml': '', - '/repo/app-config.production.local.yaml': '', - }); - - const resolved = await resolveStaticConfig({ - env: 'production', - rootPaths: ['/repo'], - }); - - expect(normalizePaths(resolved)).toEqual([ - '/repo/app-config.yaml', - '/repo/app-config.local.yaml', - '/repo/app-config.production.yaml', - '/repo/app-config.production.local.yaml', - ]); - }); -}); diff --git a/packages/config-loader/src/lib/resolver.ts b/packages/config-loader/src/lib/resolver.ts deleted file mode 100644 index 44f0f6ea9e..0000000000 --- a/packages/config-loader/src/lib/resolver.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { resolve as resolvePath } from 'path'; -import { pathExists } from 'fs-extra'; - -type ResolveOptions = { - // Root paths to search for config files. Config from earlier paths has lower priority. - rootPaths: string[]; - // The environment that we're loading config for, e.g. 'development', 'production'. - env: string; -}; - -/** - * Resolves all configuration files that should be loaded in the given environment. - * - * For each root directory, search for the default app-config.yaml, along with suffixed - * NODE_ENV and local variants, e.g. app-config.production.yaml or app-config.development.local.yaml - * - * The priority order of config loaded through suffixes is `env > local > none`, meaning that - * for example app-config.development.yaml has higher priority than `app-config.local.yaml`. - * - */ -export async function resolveStaticConfig( - options: ResolveOptions, -): Promise { - const filePaths = [ - `app-config.yaml`, - `app-config.local.yaml`, - `app-config.${options.env}.yaml`, - `app-config.${options.env}.local.yaml`, - ]; - - const resolvedPaths = []; - - for (const rootPath of options.rootPaths) { - for (const filePath of filePaths) { - const path = resolvePath(rootPath, filePath); - if (await pathExists(path)) { - resolvedPaths.push(path); - } - } - } - - return resolvedPaths; -} diff --git a/packages/config-loader/src/lib/schema/collect.test.ts b/packages/config-loader/src/lib/schema/collect.test.ts new file mode 100644 index 0000000000..7aca4f7c0c --- /dev/null +++ b/packages/config-loader/src/lib/schema/collect.test.ts @@ -0,0 +1,229 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import mockFs from 'mock-fs'; +import { collectConfigSchemas } from './collect'; + +const mockSchema = { + type: 'object', + properties: { + key: { + type: 'string', + visibility: 'frontend', + }, + }, +}; + +describe('collectConfigSchemas', () => { + afterEach(() => { + mockFs.restore(); + }); + + it('should not find any schemas without packages', async () => { + mockFs({ + 'lerna.json': JSON.stringify({ + packages: ['packages/*'], + }), + }); + + await expect(collectConfigSchemas([])).resolves.toEqual([]); + }); + + it('should find schema in a local package', async () => { + mockFs({ + node_modules: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + configSchema: mockSchema, + }), + }, + }, + }); + + await expect(collectConfigSchemas(['a'])).resolves.toEqual([ + { + path: 'node_modules/a/package.json', + value: mockSchema, + }, + ]); + }); + + it('should find schema in transitive dependencies', async () => { + mockFs({ + node_modules: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { b: '0.0.0', '@backstage/mock': '0.0.0' }, + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + c1: '0.0.0', + c2: '0.0.0', + '@backstage/mock': '0.0.0', + }, + configSchema: { ...mockSchema, title: 'b' }, + }), + }, + c1: { + 'package.json': JSON.stringify({ + name: 'c1', + dependencies: { d1: '0.0.0' }, + configSchema: { ...mockSchema, title: 'c1' }, + }), + }, + c2: { + 'package.json': JSON.stringify({ + name: 'c2', + dependencies: { d2: '0.0.0' }, + }), + }, + d1: { + 'package.json': JSON.stringify({ + name: 'd1', + dependencies: {}, + configSchema: { ...mockSchema, title: 'd1' }, + }), + }, + d2: { + 'package.json': JSON.stringify({ + name: 'd2', + dependencies: {}, + configSchema: { ...mockSchema, title: 'd2' }, + }), + }, + }, + }); + + await expect(collectConfigSchemas(['a'])).resolves.toEqual([ + { + path: 'node_modules/b/package.json', + value: { ...mockSchema, title: 'b' }, + }, + { + path: 'node_modules/c1/package.json', + value: { ...mockSchema, title: 'c1' }, + }, + { + path: 'node_modules/d1/package.json', + value: { ...mockSchema, title: 'd1' }, + }, + ]); + }); + + it('should schema of different types', async () => { + mockFs({ + node_modules: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + configSchema: { ...mockSchema, title: 'inline' }, + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + configSchema: 'schema.json', + }), + 'schema.json': JSON.stringify({ ...mockSchema, title: 'external' }), + }, + c: { + 'package.json': JSON.stringify({ + name: 'c', + configSchema: 'schema.d.ts', + }), + 'schema.d.ts': `export interface Config { + /** @visibility secret */ + tsKey: string + }`, + }, + }, + // TypeScript compilation needs to load some real files inside the typescript dir + '../../node_modules/typescript': (mockFs as any).load( + '../../node_modules/typescript', + ), + }); + + await expect(collectConfigSchemas(['a', 'b', 'c'])).resolves.toEqual([ + { + path: 'node_modules/a/package.json', + value: { ...mockSchema, title: 'inline' }, + }, + { + path: 'node_modules/b/schema.json', + value: { ...mockSchema, title: 'external' }, + }, + { + path: 'node_modules/c/schema.d.ts', + value: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + tsKey: { + type: 'string', + visibility: 'secret', + }, + }, + required: ['tsKey'], + }, + }, + ]); + }); + + it('should not allow unknown schema file types', async () => { + mockFs({ + node_modules: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + configSchema: 'schema.yaml', + }), + 'schema.yaml': mockSchema, + }, + }, + }); + + await expect(collectConfigSchemas(['a'])).rejects.toThrow( + 'Config schema files must be .json or .d.ts, got schema.yaml', + ); + }); + + it('should reject typescript config declaration without a Config type', async () => { + mockFs({ + node_modules: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + configSchema: 'schema.d.ts', + }), + 'schema.d.ts': `export interface NotConfig {}`, + }, + }, + // TypeScript compilation needs to load some real files inside the typescript dir + '../../node_modules/typescript': (mockFs as any).load( + '../../node_modules/typescript', + ), + }); + + await expect(collectConfigSchemas(['a'])).rejects.toThrow( + 'Invalid schema in node_modules/a/schema.d.ts, missing Config export', + ); + }); +}); diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts new file mode 100644 index 0000000000..0e53562875 --- /dev/null +++ b/packages/config-loader/src/lib/schema/collect.ts @@ -0,0 +1,180 @@ +/* + * 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, + relative as relativePath, + dirname, + sep, +} from 'path'; +import { ConfigSchemaPackageEntry } from './types'; +import { getProgramFromFiles, generateSchema } from 'typescript-json-schema'; +import { JsonObject } from '@backstage/config'; + +type Item = { + name: string; + parentPath?: string; +}; + +const req = + typeof __non_webpack_require__ === 'undefined' + ? require + : __non_webpack_require__; + +/** + * This collects all known config schemas across all dependencies of the app. + */ +export async function collectConfigSchemas( + packageNames: string[], +): Promise { + const visitedPackages = new Set(); + const schemas = Array(); + const tsSchemaPaths = Array(); + const currentDir = await fs.realpath(process.cwd()); + + async function processItem({ name, parentPath }: Item) { + // Ensures that we only process each package once. We don't bother with + // loading in schemas from duplicates of different versions, as that's not + // supported by Backstage right now anyway. We may want to change that in + // the future though, if it for example becomes possible to load in two + // different versions of e.g. @backstage/core at once. + if (visitedPackages.has(name)) { + return; + } + visitedPackages.add(name); + + let pkgPath: string; + try { + pkgPath = req.resolve( + `${name}/package.json`, + parentPath && { + paths: [parentPath], + }, + ); + } catch { + // We can somewhat safely ignore packages that don't export package.json, + // as they are likely not part of the Backstage ecosystem anyway. + return; + } + + const pkg = await fs.readJson(pkgPath); + const depNames = [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.peerDependencies ?? {}), + ]; + + // TODO(Rugvip): Trying this out to avoid having to traverse the full dependency graph, + // since that's pretty slow. We probably need a better way to determine when + // we've left the Backstage ecosystem, but this will do for now. + const hasSchema = 'configSchema' in pkg; + const hasBackstageDep = depNames.some(_ => _.startsWith('@backstage/')); + if (!hasSchema && !hasBackstageDep) { + return; + } + if (hasSchema) { + if (typeof pkg.configSchema === 'string') { + const isJson = pkg.configSchema.endsWith('.json'); + const isDts = pkg.configSchema.endsWith('.d.ts'); + if (!isJson && !isDts) { + throw new Error( + `Config schema files must be .json or .d.ts, got ${pkg.configSchema}`, + ); + } + if (isDts) { + tsSchemaPaths.push( + relativePath( + currentDir, + resolvePath(dirname(pkgPath), pkg.configSchema), + ), + ); + } else { + const path = resolvePath(dirname(pkgPath), pkg.configSchema); + const value = await fs.readJson(path); + schemas.push({ + value, + path: relativePath(currentDir, path), + }); + } + } else { + schemas.push({ + value: pkg.configSchema, + path: relativePath(currentDir, pkgPath), + }); + } + } + + await Promise.all( + depNames.map(name => processItem({ name, parentPath: pkgPath })), + ); + } + + await Promise.all(packageNames.map(name => processItem({ name }))); + + const tsSchemas = compileTsSchemas(tsSchemaPaths); + + return schemas.concat(tsSchemas); +} + +// This handles the support of TypeScript .d.ts config schema declarations. +// We collect all typescript schema definition and compile them all in one go. +// This is much faster than compiling them separately. +function compileTsSchemas(paths: string[]) { + if (paths.length === 0) { + return []; + } + + const program = getProgramFromFiles(paths, { + incremental: false, + isolatedModules: true, + lib: ['ES5'], // Skipping most libs speeds processing up a lot, we just need the primitive types anyway + noEmit: true, + noResolve: true, + skipLibCheck: true, // Skipping lib checks speeds things up + skipDefaultLibCheck: true, + strict: true, + typeRoots: [], // Do not include any additional types + types: [], + }); + + const tsSchemas = paths.map(path => { + let value; + try { + value = generateSchema( + program, + // All schemas should export a `Config` symbol + 'Config', + // This enables usage of @visibility is doc comments + { + required: true, + validationKeywords: ['visibility'], + }, + [path.split(sep).join('/')], // Unix paths are expected for all OSes here + ) as JsonObject | null; + } catch (error) { + if (error.message !== 'type Config not found') { + throw error; + } + } + + if (!value) { + throw new Error(`Invalid schema in ${path}, missing Config export`); + } + return { path, value }; + }); + + return tsSchemas; +} diff --git a/packages/config-loader/src/lib/schema/compile.test.ts b/packages/config-loader/src/lib/schema/compile.test.ts new file mode 100644 index 0000000000..91e7aa687e --- /dev/null +++ b/packages/config-loader/src/lib/schema/compile.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 { compileConfigSchemas } from './compile'; + +describe('compileConfigSchemas', () => { + it('should merge schemas', () => { + const validate = compileConfigSchemas([ + { + path: 'a', + value: { type: 'object', properties: { a: { type: 'string' } } }, + }, + { + path: 'b', + value: { type: 'object', properties: { b: { type: 'number' } } }, + }, + ]); + expect(validate([{ data: { a: 1 }, context: 'test' }])).toEqual({ + errors: ['Config should be string { type=string } at .a'], + visibilityByPath: new Map(), + }); + expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({ + errors: ['Config should be number { type=number } at .b'], + visibilityByPath: new Map(), + }); + }); + + it('should discover visibilities', () => { + const validate = compileConfigSchemas([ + { + path: 'a1', + value: { + type: 'object', + properties: { + a: { type: 'string', visibility: 'frontend' }, + b: { type: 'string', visibility: 'backend' }, + c: { type: 'string' }, + d: { + type: 'array', + visibility: 'secret', + items: { type: 'string', visibility: 'frontend' }, + }, + }, + }, + }, + { + path: 'a2', + value: { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'string', visibility: 'secret' }, + c: { type: 'string', visibility: 'backend' }, + d: { + type: 'array', + visibility: 'secret', + items: { type: 'string' }, + }, + }, + }, + }, + ]); + expect( + validate([ + { data: { a: 'a', b: 'b', c: 'c', d: ['d'] }, context: 'test' }, + ]), + ).toEqual({ + visibilityByPath: new Map( + Object.entries({ + '.a': 'frontend', + '.b': 'secret', + '.d': 'secret', + '.d.0': 'frontend', + }), + ), + }); + }); + + it('should reject visiblity conflicts', () => { + expect(() => + compileConfigSchemas([ + { + path: 'a1', + value: { + type: 'object', + properties: { a: { type: 'string', visibility: 'frontend' } }, + }, + }, + { + path: 'a2', + value: { + type: 'object', + properties: { a: { type: 'string', visibility: 'secret' } }, + }, + }, + ]), + ).toThrow( + "Config schema visibility is both 'frontend' and 'secret' for properties/a/visibility", + ); + }); +}); diff --git a/packages/config-loader/src/lib/schema/compile.ts b/packages/config-loader/src/lib/schema/compile.ts new file mode 100644 index 0000000000..f01a4f640a --- /dev/null +++ b/packages/config-loader/src/lib/schema/compile.ts @@ -0,0 +1,126 @@ +/* + * 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 Ajv from 'ajv'; +import { JSONSchema7 as JSONSchema } from 'json-schema'; +import mergeAllOf, { Resolvers } from 'json-schema-merge-allof'; +import { ConfigReader } from '@backstage/config'; +import { + ConfigSchemaPackageEntry, + ValidationFunc, + CONFIG_VISIBILITIES, + ConfigVisibility, +} from './types'; + +/** + * This takes a collection of Backstage configuration schemas from various + * sources and compiles them down into a single schema validation function. + * + * It also handles the implementation of the custom "visibility" keyword used + * to specify the scope of different config paths. + */ +export function compileConfigSchemas( + schemas: ConfigSchemaPackageEntry[], +): ValidationFunc { + // The ajv instance below is stateful and doesn't really allow for additional + // output during validation. We work around this by having this extra piece + // of state that we reset before each validation. + const visibilityByPath = new Map(); + + const ajv = new Ajv({ + allErrors: true, + schemas: { + 'https://backstage.io/schema/config-v1': true, + }, + }).addKeyword('visibility', { + metaSchema: { + type: 'string', + enum: CONFIG_VISIBILITIES, + }, + compile(visibility: ConfigVisibility) { + return (_data, dataPath) => { + if (!dataPath) { + return false; + } + if (visibility && visibility !== 'backend') { + const normalizedPath = dataPath.replace( + /\['?(.*?)'?\]/g, + (_, segment) => `.${segment}`, + ); + visibilityByPath.set(normalizedPath, visibility); + } + return true; + }; + }, + }); + + const merged = mergeAllOf( + { allOf: schemas.map(_ => _.value) }, + { + // JSONSchema is typically subtractive, as in it always reduces the set of allowed + // inputs through constraints. This changes the object property merging to be additive + // rather than subtractive. + ignoreAdditionalProperties: true, + resolvers: { + // This ensures that the visibilities across different schemas are sound, and + // selects the most specific visibility for each path. + visibility(values: string[], path: string[]) { + const hasFrontend = values.some(_ => _ === 'frontend'); + const hasSecret = values.some(_ => _ === 'secret'); + if (hasFrontend && hasSecret) { + throw new Error( + `Config schema visibility is both 'frontend' and 'secret' for ${path.join( + '/', + )}`, + ); + } else if (hasFrontend) { + return 'frontend'; + } else if (hasSecret) { + return 'secret'; + } + + return 'backend'; + }, + } as Partial>, + }, + ); + + const validate = ajv.compile(merged); + + return configs => { + const config = ConfigReader.fromConfigs(configs).get(); + + visibilityByPath.clear(); + + const valid = validate(config); + if (!valid) { + const errors = validate.errors ?? []; + return { + errors: errors.map(({ dataPath, message, params }) => { + const paramStr = Object.entries(params) + .map(([name, value]) => `${name}=${value}`) + .join(' '); + return `Config ${message || ''} { ${paramStr} } at ${dataPath}`; + }), + visibilityByPath: new Map(), + }; + } + + return { + visibilityByPath: new Map(visibilityByPath), + }; + }; +} diff --git a/packages/config-loader/src/lib/schema/filtering.test.ts b/packages/config-loader/src/lib/schema/filtering.test.ts new file mode 100644 index 0000000000..bfad2ca95a --- /dev/null +++ b/packages/config-loader/src/lib/schema/filtering.test.ts @@ -0,0 +1,105 @@ +/* + * 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'; +import { ConfigVisibility } from './types'; +import { filterByVisibility } from './filtering'; + +const data = { + arr: ['f', 'b', 's'], + objArr: [ + { f: 1, b: 2, s: 3 }, + { f: 4, b: 5, s: 6 }, + ], + obj: { + f: 'a', + b: { + s: true, + }, + }, + arrF: [{ never: 'here' }], + arrB: [{ never: 'here' }], + arrS: [{ never: 'here' }], + objF: { never: 'here' }, + objB: { never: 'here' }, + objS: { never: 'here' }, +}; + +const visiblity = new Map( + Object.entries({ + '.arr.0': 'frontend', + '.arr.1': 'backend', + '.arr.2': 'secret', + '.obj.f': 'frontend', + '.obj.b': 'backend', + '.obj.b.s': 'secret', + '.objArr.0.f': 'frontend', + '.objArr.0.b': 'backend', + '.objArr.0.s': 'secret', + '.objArr.1.f': 'frontend', + '.objArr.1.b': 'backend', + '.objArr.1.s': 'secret', + '.arrF': 'frontend', + '.arrB': 'backend', + '.arrS': 'secret', + '.objF': 'frontend', + '.objB': 'backend', + '.objS': 'secret', + }), +); + +describe('filterByVisibility', () => { + test.each<[ConfigVisibility[], JsonObject]>([ + [[], {}], + [ + ['frontend'], + { + arr: ['f'], + objArr: [{ f: 1 }, { f: 4 }], + obj: { f: 'a' }, + arrF: [], + objF: {}, + }, + ], + [ + ['backend'], + { + arr: ['b'], + objArr: [{ b: 2 }, { b: 5 }], + obj: { b: {} }, + arrF: [{ never: 'here' }], + arrB: [{ never: 'here' }], + arrS: [{ never: 'here' }], + objF: { never: 'here' }, + objB: { never: 'here' }, + objS: { never: 'here' }, + }, + ], + [ + ['secret'], + { + arr: ['s'], + objArr: [{ s: 3 }, { s: 6 }], + obj: { b: { s: true } }, + arrS: [], + objS: {}, + }, + ], + [['frontend', 'backend', 'secret'], data], + ])('should filter correctly with %p', (filter, expected) => { + expect(filterByVisibility(data, filter, visiblity)).toEqual(expected); + }); +}); diff --git a/packages/config-loader/src/lib/schema/filtering.ts b/packages/config-loader/src/lib/schema/filtering.ts new file mode 100644 index 0000000000..10a97f9a7f --- /dev/null +++ b/packages/config-loader/src/lib/schema/filtering.ts @@ -0,0 +1,85 @@ +/* + * 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, JsonValue } from '@backstage/config'; +import { + ConfigVisibility, + DEFAULT_CONFIG_VISIBILITY, + TransformFunc, +} from './types'; + +/** + * This filters data by visibility by discovering the visibility of each + * value, and then only keeping the ones that are specified in `includeVisibilities`. + */ +export function filterByVisibility( + data: JsonObject, + includeVisibilities: ConfigVisibility[], + visibilityByPath: Map, + transformFunc?: TransformFunc, +): JsonObject { + function transform(jsonVal: JsonValue, path: string): JsonValue | undefined { + const visibility = visibilityByPath.get(path) ?? DEFAULT_CONFIG_VISIBILITY; + const isVisible = includeVisibilities.includes(visibility); + + if (typeof jsonVal !== 'object') { + if (isVisible) { + if (transformFunc) { + return transformFunc(jsonVal, { visibility }); + } + return jsonVal; + } + return undefined; + } else if (jsonVal === null) { + return undefined; + } else if (Array.isArray(jsonVal)) { + const arr = new Array(); + + for (const [index, value] of jsonVal.entries()) { + const out = transform(value, `${path}.${index}`); + if (out !== undefined) { + arr.push(out); + } + } + + if (arr.length > 0 || isVisible) { + return arr; + } + return undefined; + } + + const outObj: JsonObject = {}; + let hasOutput = false; + + for (const [key, value] of Object.entries(jsonVal)) { + if (value === undefined) { + continue; + } + const out = transform(value, `${path}.${key}`); + if (out !== undefined) { + outObj[key] = out; + hasOutput = true; + } + } + + if (hasOutput || isVisible) { + return outObj; + } + return undefined; + } + + return (transform(data, '') as JsonObject) ?? {}; +} diff --git a/packages/config-loader/src/lib/schema/index.ts b/packages/config-loader/src/lib/schema/index.ts new file mode 100644 index 0000000000..8cefb93b3c --- /dev/null +++ b/packages/config-loader/src/lib/schema/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { loadConfigSchema } from './load'; +export type { ConfigSchema, ConfigVisibility } from './types'; diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts new file mode 100644 index 0000000000..a13f36be20 --- /dev/null +++ b/packages/config-loader/src/lib/schema/load.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import mockFs from 'mock-fs'; +import { loadConfigSchema } from './load'; + +describe('loadConfigSchema', () => { + afterEach(() => { + mockFs.restore(); + }); + + it('should load schema from packages or data', async () => { + mockFs({ + node_modules: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + configSchema: { + type: 'object', + properties: { + key1: { type: 'string', visibility: 'frontend' }, + }, + }, + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + configSchema: 'schema.json', + }), + 'schema.json': JSON.stringify({ + name: 'a', + configSchema: { + type: 'object', + properties: { + key2: { type: 'number' }, + }, + }, + }), + }, + }, + }); + + const schema = await loadConfigSchema({ + dependencies: ['a'], + }); + + const configs = [{ data: { key1: 'a', key2: 2 }, context: 'test' }]; + + expect(schema.process(configs)).toEqual(configs); + expect(schema.process(configs, { visiblity: ['frontend'] })).toEqual([ + { data: { key1: 'a' }, context: 'test' }, + ]); + expect( + schema.process(configs, { + visiblity: ['frontend'], + valueTransform: () => 'X', + }), + ).toEqual([{ data: { key1: 'X' }, context: 'test' }]); + expect( + schema.process(configs, { + valueTransform: () => 'X', + }), + ).toEqual([{ data: { key1: 'X', key2: 'X' }, context: 'test' }]); + + const serialized = schema.serialize(); + + const schema2 = await loadConfigSchema({ serialized }); + expect(schema2.process(configs, { visiblity: ['frontend'] })).toEqual([ + { data: { key1: 'a' }, context: 'test' }, + ]); + expect(() => + schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]), + ).toThrow( + 'Config validation failed, Config should be string { type=string } at .key1', + ); + + await expect( + loadConfigSchema({ + serialized: { ...serialized, backstageConfigSchemaVersion: 2 }, + }), + ).rejects.toThrow( + 'Serialized configuration schema is invalid or has an invalid version number', + ); + }); +}); diff --git a/packages/config-loader/src/lib/schema/load.ts b/packages/config-loader/src/lib/schema/load.ts new file mode 100644 index 0000000000..01a9499983 --- /dev/null +++ b/packages/config-loader/src/lib/schema/load.ts @@ -0,0 +1,104 @@ +/* + * 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'; +import { compileConfigSchemas } from './compile'; +import { collectConfigSchemas } from './collect'; +import { filterByVisibility } from './filtering'; +import { + ConfigSchema, + ConfigSchemaPackageEntry, + CONFIG_VISIBILITIES, +} from './types'; + +type Options = + | { + dependencies: string[]; + } + | { + serialized: JsonObject; + }; + +/** + * Loads config schema for a Backstage instance. + */ +export async function loadConfigSchema( + options: Options, +): Promise { + let schemas: ConfigSchemaPackageEntry[]; + + if ('dependencies' in options) { + schemas = await collectConfigSchemas(options.dependencies); + } else { + const { serialized } = options; + if (serialized?.backstageConfigSchemaVersion !== 1) { + throw new Error( + 'Serialized configuration schema is invalid or has an invalid version number', + ); + } + schemas = serialized.schemas as ConfigSchemaPackageEntry[]; + } + + const validate = compileConfigSchemas(schemas); + + return { + process( + configs: AppConfig[], + { visiblity, valueTransform } = {}, + ): AppConfig[] { + const result = validate(configs); + if (result.errors) { + const error = new Error( + `Config validation failed, ${result.errors.join('; ')}`, + ); + (error as any).messages = result.errors; + throw error; + } + + let processedConfigs = configs; + + if (visiblity) { + processedConfigs = processedConfigs.map(({ data, context }) => ({ + context, + data: filterByVisibility( + data, + visiblity, + result.visibilityByPath, + valueTransform, + ), + })); + } else if (valueTransform) { + processedConfigs = processedConfigs.map(({ data, context }) => ({ + context, + data: filterByVisibility( + data, + Array.from(CONFIG_VISIBILITIES), + result.visibilityByPath, + valueTransform, + ), + })); + } + + return processedConfigs; + }, + serialize(): JsonObject { + return { + schemas, + backstageConfigSchemaVersion: 1, + }; + }, + }; +} diff --git a/packages/config-loader/src/lib/schema/types.ts b/packages/config-loader/src/lib/schema/types.ts new file mode 100644 index 0000000000..7705242b31 --- /dev/null +++ b/packages/config-loader/src/lib/schema/types.ts @@ -0,0 +1,111 @@ +/* + * 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'; + +/** + * An sub-set of configuration schema. + */ +export type ConfigSchemaPackageEntry = { + /** + * The configuration schema itself. + */ + value: JsonObject; + /** + * The relative path that the configuration schema was discovered at. + */ + path: string; +}; + +/** + * A list of all possible configuration value visibilities. + */ +export const CONFIG_VISIBILITIES = ['frontend', 'backend', 'secret'] as const; + +/** + * A type representing the possible configuration value visibilities + */ +export type ConfigVisibility = typeof CONFIG_VISIBILITIES[number]; + +/** + * The default configuration visibility if no other values is given. + */ +export const DEFAULT_CONFIG_VISIBILITY: ConfigVisibility = 'backend'; + +/** + * An explanation of a configuration validation error. + */ +type ValidationError = string; + +/** + * The result of validating configuration data using a schema. + */ +type ValidationResult = { + /** + * Errors that where emitted during validation, if any. + */ + errors?: ValidationError[]; + /** + * The configuration visibilities that were discovered during validation. + * + * The path in the key uses the form `////` + */ + visibilityByPath: Map; +}; + +/** + * A function used validate configuration data. + */ +export type ValidationFunc = (configs: AppConfig[]) => ValidationResult; + +/** + * A function used to transform primitive configuration values. + */ +export type TransformFunc = ( + value: T, + context: { visibility: ConfigVisibility }, +) => T | undefined; + +/** + * Options used to process configuration data with a schema. + */ +type ConfigProcessingOptions = { + /** + * The visibilities that should be included in the output data. + * If omitted, the data will not be filtered by visibility. + */ + visiblity?: ConfigVisibility[]; + + /** + * A transform function that can be used to transform primitive configuration values + * during validation. The value returned from the transform function will be used + * instead of the original value. If the transform returns `undefined`, the value + * will be omitted. + */ + valueTransform?: TransformFunc; +}; + +/** + * A loaded configuration schema that is ready to process configuration data. + */ +export type ConfigSchema = { + process( + appConfigs: AppConfig[], + options?: ConfigProcessingOptions, + ): AppConfig[]; + + serialize(): JsonObject; +}; diff --git a/packages/config-loader/src/lib/secrets.test.ts b/packages/config-loader/src/lib/secrets.test.ts index cfc4150d68..d80ada193b 100644 --- a/packages/config-loader/src/lib/secrets.test.ts +++ b/packages/config-loader/src/lib/secrets.test.ts @@ -21,7 +21,6 @@ const ctx: ReaderContext = { env: { SECRET: 'my-secret', }, - skip: () => false, readSecret: jest.fn(), async readFile(path) { const content = ({ diff --git a/packages/config-loader/src/lib/secrets.ts b/packages/config-loader/src/lib/secrets.ts index 4c3812d527..97f0de2940 100644 --- a/packages/config-loader/src/lib/secrets.ts +++ b/packages/config-loader/src/lib/secrets.ts @@ -58,7 +58,7 @@ const secretLoaderSchemas = { }; // The top-level secret schema, which figures out what type of secret it is. -const secretSchema = yup.lazy(value => { +const secretSchema = yup.lazy(value => { if (typeof value !== 'object' || value === null) { return yup.object().required().label('secret'); } diff --git a/packages/config-loader/src/lib/types.ts b/packages/config-loader/src/lib/types.ts index 02b8a9d053..e189aef20d 100644 --- a/packages/config-loader/src/lib/types.ts +++ b/packages/config-loader/src/lib/types.ts @@ -28,7 +28,6 @@ export type SkipFunc = (path: string) => boolean; */ export type ReaderContext = { env: { [name in string]?: string }; - skip: SkipFunc; readFile: ReadFileFunc; readSecret: ReadSecretFunc; }; diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index 087e2309c0..a9857a784f 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -38,31 +38,12 @@ describe('loadConfig', () => { mockFs.restore(); }); - it('loads config without secrets', async () => { + it('load config from default path', async () => { await expect( loadConfig({ - rootPaths: ['/root'], + configRoot: '/root', + configPaths: [], env: 'production', - shouldReadSecrets: false, - }), - ).resolves.toEqual([ - { - context: 'app-config.yaml', - data: { - app: { - title: 'Example App', - }, - }, - }, - ]); - }); - - it('loads config with secrets', async () => { - await expect( - loadConfig({ - rootPaths: ['/root'], - env: 'production', - shouldReadSecrets: true, }), ).resolves.toEqual([ { @@ -77,12 +58,12 @@ describe('loadConfig', () => { ]); }); - it('loads development config without secrets', async () => { + it('loads config with secrets', async () => { await expect( loadConfig({ - rootPaths: ['/root'], - env: 'development', - shouldReadSecrets: false, + configRoot: '/root', + configPaths: ['/root/app-config.yaml'], + env: 'production', }), ).resolves.toEqual([ { @@ -90,24 +71,22 @@ describe('loadConfig', () => { data: { app: { title: 'Example App', + sessionKey: 'abc123', }, }, }, - { - context: 'app-config.development.yaml', - data: { - app: {}, - }, - }, ]); }); it('loads development config with secrets', async () => { await expect( loadConfig({ - rootPaths: ['/root'], + configRoot: '/root', + configPaths: [ + '/root/app-config.yaml', + '/root/app-config.development.yaml', + ], env: 'development', - shouldReadSecrets: true, }), ).resolves.toEqual([ { diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index cf5edd3ce3..a647367469 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -15,33 +15,26 @@ */ import fs from 'fs-extra'; -import { resolve as resolvePath, dirname } from 'path'; +import { resolve as resolvePath, dirname, isAbsolute } from 'path'; import { AppConfig, JsonObject } from '@backstage/config'; -import { - resolveStaticConfig, - readConfigFile, - readEnvConfig, - readSecret, -} from './lib'; +import { readConfigFile, readEnvConfig, readSecret } from './lib'; export type LoadConfigOptions = { - // Root paths to search for config files. Config from earlier paths has lower priority. - rootPaths: string[]; + // The root directory of the config loading context. Used to find default configs. + configRoot: string; - // The environment that we're loading config for, e.g. 'development', 'production'. + // Absolute paths to load config files from. Configs from earlier paths have lower priority. + configPaths: string[]; + + // TODO(Rugvip): This will be removed in the future, but for now we use it to warn about possible mistakes. env: string; - - // Whether to read secrets or omit them, defaults to false. - shouldReadSecrets?: boolean; }; class Context { constructor( private readonly options: { - secretPaths: Set; env: { [name in string]?: string }; rootPath: string; - shouldReadSecrets: boolean; }, ) {} @@ -49,26 +42,14 @@ class Context { return this.options.env; } - skip(path: string): boolean { - if (this.options.shouldReadSecrets) { - return false; - } - return this.options.secretPaths.has(path); - } - async readFile(path: string): Promise { return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8'); } async readSecret( - path: string, + _path: string, desc: JsonObject, ): Promise { - this.options.secretPaths.add(path); - if (!this.options.shouldReadSecrets) { - return undefined; - } - return readSecret(desc, this); } } @@ -77,20 +58,40 @@ export async function loadConfig( options: LoadConfigOptions, ): Promise { const configs = []; + const { configRoot } = options; + const configPaths = options.configPaths.slice(); - const configPaths = await resolveStaticConfig(options); + // If no paths are provided, we default to reading + // `app-config.yaml` and, if it exists, `app-config.local.yaml` + if (configPaths.length === 0) { + configPaths.push(resolvePath(configRoot, 'app-config.yaml')); + + const localConfig = resolvePath(configRoot, 'app-config.local.yaml'); + if (await fs.pathExists(localConfig)) { + configPaths.push(localConfig); + } + + const envFile = `app-config.${options.env}.yaml`; + if (await fs.pathExists(resolvePath(configRoot, envFile))) { + console.error( + `Env config file '${envFile}' is not loaded as APP_ENV and NODE_ENV-based config loading has been removed`, + ); + console.error( + `To load the config file, use --config , listing every config file that you want to load`, + ); + } + } try { - const secretPaths = new Set(); - for (const configPath of configPaths) { + if (!isAbsolute(configPath)) { + throw new Error(`Config load path is not absolute: '${configPath}'`); + } const config = await readConfigFile( configPath, new Context({ - secretPaths, env: process.env, rootPath: dirname(configPath), - shouldReadSecrets: Boolean(options.shouldReadSecrets), }), ); diff --git a/packages/config/README.md b/packages/config/README.md index f532c682be..9866b1bce9 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -8,5 +8,5 @@ Do not install this package directly, it is an internal package used by [@backst ## Documentation -- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/config/package.json b/packages/config/package.json index d3695ebf33..f1ae07ef64 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config", "description": "Config API used by Backstage core, backend, and CLI", - "version": "0.1.1-alpha.24", + "version": "0.1.1", "private": false, "publishConfig": { "access": "public", @@ -12,7 +12,7 @@ "homepage": "https://backstage.io", "repository": { "type": "git", - "url": "https://github.com/spotify/backstage", + "url": "https://github.com/backstage/backstage", "directory": "packages/config" }, "keywords": [ diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md new file mode 100644 index 0000000000..b936715c70 --- /dev/null +++ b/packages/core-api/CHANGELOG.md @@ -0,0 +1,123 @@ +# @backstage/core-api + +## 0.2.1 + +### Patch Changes + +- c5bab94ab: Updated the AuthApi `.create` methods to configure the default scope of the corresponding Auth Api. As a result the + default scope is configurable when overwriting the Core Api in the app. + + ``` + GithubAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user', 'repo'], + }), + ``` + + Replaced redundant CreateOptions of each Auth Api with the OAuthApiCreateOptions type. + + ``` + export type OAuthApiCreateOptions = AuthApiCreateOptions & { + oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; + }; + + export type AuthApiCreateOptions = { + discoveryApi: DiscoveryApi; + environment?: string; + provider?: AuthProvider & { id: string }; + }; + ``` + +- Updated dependencies [4577e377b] + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 819a70229: Add SAML login to backstage + + ![](https://user-images.githubusercontent.com/872486/92251660-bb9e3400-eeff-11ea-86fe-1f2a0262cd31.png) + + ![](https://user-images.githubusercontent.com/872486/93851658-1a76f200-fce3-11ea-990b-26ca1a327a15.png) + +- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the Github Auth Api. As a result the + default scope is configurable when overwriting the Core Api in the app. + + ``` + GithubAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user', 'repo'], + }), + ``` + +- cbab5bbf8: Refactored the FeatureFlagsApi to make it easier to re-implement. Existing usage of particularly getUserFlags can be replaced with isActive() or save(). + +### Patch Changes + +- cbbd271c4: Add initial RouteRefRegistry + + Starting out some work to bring routing back and working as part of the work towards finalizing #1536 + + This is some of the groundwork of an experiment we're working on to enable routing via RouteRefs, while letting the app itself look something like this: + + ```jsx + const App = () => ( + + + + {' '} + // catalogRouteRef + + + + + + + // statusRouteRef + + + + + + + + + + // sentryRouteRef + + + + + + + + + + + + + + + + ); + ``` + + As part of inverting the composition of the app, route refs and routing in general was somewhat broken, intentionally. Right now it's not really possible to easily route to different parts of the app from a plugin, or even different parts of the plugin that are not within the same router. + + The core part of the experiment is to construct a map of ApiRef[] -> path overrides. Each key in the map is the list of route refs to traversed to reach a leaf in the routing tree, and the value is the path override at that point. For example, the above tree would add entries like [techDocsRouteRef] -> '/docs', and [entityRouteRef, apiDocsRouteRef] -> '/api'. By mapping out the entire app in this structure, the idea is that we can navigate to any point in the app using RouteRefs. + + The RouteRefRegistry is an implementation of such a map, and the idea is to add it in master to make it a bit easier to experiment and iterate. This is not an exposed API at this point. + + We've explored a couple of alternatives for how to enable routing, but it's boiled down to either a solution centred around the route map mentioned above, or treating all routes as static and globally unique, with no room for flexibility, customization or conflicts between different plugins. We're starting out pursuing this options 😁. We also expect that a the app-wide routing table will make things like dynamic loading a lot cleaner, as there would be a much more clear handoff between the main chunk and dynamic chunks. + +- 26e69ab1a: Remove cost insights example client from demo app and export from plugin + Create cost insights dev plugin using example client + Make PluginConfig and dependent types public +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] + - @backstage/theme@0.2.0 + - @backstage/test-utils@0.1.2 diff --git a/packages/core-api/README.md b/packages/core-api/README.md index 5732b5ef23..f7b8b6c337 100644 --- a/packages/core-api/README.md +++ b/packages/core-api/README.md @@ -18,5 +18,5 @@ $ yarn add @backstage/core ## Documentation -- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/core-api/package.json b/packages/core-api/package.json index c925471695..212d4c9046 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.24", + "version": "0.2.1", "private": false, "publishConfig": { "access": "public", @@ -11,7 +11,7 @@ "homepage": "https://backstage.io", "repository": { "type": "git", - "url": "https://github.com/spotify/backstage", + "url": "https://github.com/backstage/backstage", "directory": "packages/core-api" }, "keywords": [ @@ -29,8 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1", + "@backstage/test-utils": "^0.1.3", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", @@ -41,15 +42,16 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/test-utils-core": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/zen-observable": "^0.8.0", - "jest-fetch-mock": "^3.0.3" + "cross-fetch": "^3.0.6", + "msw": "^0.21.3" }, "files": [ "dist" diff --git a/packages/core-api/src/apis/definitions/AlertApi.ts b/packages/core-api/src/apis/definitions/AlertApi.ts index 123cd47651..91641ac35b 100644 --- a/packages/core-api/src/apis/definitions/AlertApi.ts +++ b/packages/core-api/src/apis/definitions/AlertApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { createApiRef, ApiRef } from '../system'; import { Observable } from '../../types'; export type AlertMessage = { @@ -38,7 +38,7 @@ export type AlertApi = { alert$(): Observable; }; -export const alertApiRef = createApiRef({ +export const alertApiRef: ApiRef = createApiRef({ id: 'core.alert', description: 'Used to report alerts and forward them to the app', }); diff --git a/packages/core-api/src/apis/definitions/AppThemeApi.ts b/packages/core-api/src/apis/definitions/AppThemeApi.ts index de70adabd9..515e8df082 100644 --- a/packages/core-api/src/apis/definitions/AppThemeApi.ts +++ b/packages/core-api/src/apis/definitions/AppThemeApi.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { BackstageTheme } from '@backstage/theme'; import { Observable } from '../../types'; +import { SvgIconProps } from '@material-ui/core'; /** * Describes a theme provided by the app. @@ -41,6 +42,11 @@ export type AppTheme = { * The specialized MaterialUI theme instance. */ theme: BackstageTheme; + + /** + * An Icon for the theme mode setting. + */ + icon?: React.ReactElement; }; /** @@ -71,7 +77,7 @@ export type AppThemeApi = { setActiveThemeId(themeId?: string): void; }; -export const appThemeApiRef = createApiRef({ +export const appThemeApiRef: ApiRef = createApiRef({ id: 'core.apptheme', description: 'API Used to configure the app theme, and enumerate options', }); diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts index 1fa6e70d1f..2ce972af0e 100644 --- a/packages/core-api/src/apis/definitions/ConfigApi.ts +++ b/packages/core-api/src/apis/definitions/ConfigApi.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { Config } from '@backstage/config'; // Using interface to make the ConfigApi name show up in docs export type ConfigApi = Config; -export const configApiRef = createApiRef({ +export const configApiRef: ApiRef = createApiRef({ id: 'core.config', description: 'Used to access runtime configuration', }); diff --git a/packages/core-api/src/apis/definitions/DiscoveryApi.ts b/packages/core-api/src/apis/definitions/DiscoveryApi.ts index b0773086c7..9777835ec8 100644 --- a/packages/core-api/src/apis/definitions/DiscoveryApi.ts +++ b/packages/core-api/src/apis/definitions/DiscoveryApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; /** * The discovery API is used to provide a mechanism for plugins to @@ -41,7 +41,7 @@ export type DiscoveryApi = { getBaseUrl(pluginId: string): Promise; }; -export const discoveryApiRef = createApiRef({ +export const discoveryApiRef: ApiRef = createApiRef({ id: 'core.discovery', description: 'Provides service discovery of backend plugins', }); diff --git a/packages/core-api/src/apis/definitions/ErrorApi.ts b/packages/core-api/src/apis/definitions/ErrorApi.ts index 7f9676ea5a..edd6966968 100644 --- a/packages/core-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-api/src/apis/definitions/ErrorApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { Observable } from '../../types'; /** @@ -62,7 +62,7 @@ export type ErrorApi = { error$(): Observable<{ error: Error; context?: ErrorContext }>; }; -export const errorApiRef = createApiRef({ +export const errorApiRef: ApiRef = createApiRef({ id: 'core.error', description: 'Used to report errors and forward them to the app', }); diff --git a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts index b6aac75620..243af562b1 100644 --- a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts +++ b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; -import { - UserFlags, - FeatureFlagsRegistry, - FeatureFlagsRegistryItem, -} from '../../app/FeatureFlags'; +import { ApiRef, createApiRef } from '../system'; /** * The feature flags API is used to toggle functionality to users across plugins and Backstage. @@ -33,29 +28,59 @@ import { * to enable and disable feature flags, this API acts as another way to enable/disable. */ +export type FeatureFlag = { + name: string; + pluginId: string; +}; + export enum FeatureFlagState { - Off = 0, - On = 1, + None = 0, + Active = 1, } +/** + * Options to use when saving feature flags. + */ +export type FeatureFlagsSaveOptions = { + /** + * The new feature flag states to save. + */ + states: Record; + + /** + * Whether the saves states should be merged into the existing ones, or replace them. + * + * Defaults to false. + */ + merge?: boolean; +}; + +export type UserFlags = {}; + export interface FeatureFlagsApi { /** - * Store a list of registered feature flags. + * Registers a new feature flag. Once a feature flag has been registered it + * can be toggled by users, and read back to enable or disable features. */ - registeredFeatureFlags: FeatureFlagsRegistryItem[]; - - /** - * Get a list of all feature flags from the current user. - */ - getFlags(): UserFlags; + registerFlag(flag: FeatureFlag): void; /** * Get a list of all registered flags. */ - getRegisteredFlags(): FeatureFlagsRegistry; + getRegisteredFlags(): FeatureFlag[]; + + /** + * Whether the feature flag with the given name is currently activated for the user. + */ + isActive(name: string): boolean; + + /** + * Save the user's choice of feature flag states. + */ + save(options: FeatureFlagsSaveOptions): void; } -export const featureFlagsApiRef = createApiRef({ +export const featureFlagsApiRef: ApiRef = createApiRef({ id: 'core.featureflags', description: 'Used to toggle functionality in features across Backstage', }); diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts index 2684422b1e..5cfc4723e0 100644 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { ProfileInfo } from './auth'; /** @@ -51,7 +51,7 @@ export type IdentityApi = { signOut(): Promise; }; -export const identityApiRef = createApiRef({ +export const identityApiRef: ApiRef = createApiRef({ id: 'core.identity', description: 'Provides access to the identity of the signed in user', }); diff --git a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts index 6fefbd1b9c..b9776ed037 100644 --- a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts @@ -16,7 +16,7 @@ import { IconComponent } from '../../icons'; import { Observable } from '../../types'; -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; /** * Information about the auth provider that we're requesting a login towards. @@ -99,7 +99,7 @@ export type PendingAuthRequest = { export type OAuthRequestApi = { /** * A utility for showing login popups or similar things, and merging together multiple requests for - * different scopes into one request that inclues all scopes. + * different scopes into one request that includes all scopes. * * The passed in options provide information about the login provider, and how to handle auth requests. * @@ -114,7 +114,7 @@ export type OAuthRequestApi = { ): AuthRequester; /** - * Observers panding auth requests. The returned observable will emit all + * Observers pending auth requests. The returned observable will emit all * current active auth request, at most one for each created auth requester. * * Each request has its own info about the login provider, forwarded from the auth requester options. @@ -127,7 +127,7 @@ export type OAuthRequestApi = { authRequest$(): Observable; }; -export const oauthRequestApiRef = createApiRef({ +export const oauthRequestApiRef: ApiRef = createApiRef({ id: 'core.oauthrequest', description: 'An API for implementing unified OAuth flows in Backstage', }); diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts index 920ee56811..2482af6d63 100644 --- a/packages/core-api/src/apis/definitions/StorageApi.ts +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; +import { ApiRef, createApiRef } from '../system'; import { Observable } from '../../types'; import { ErrorApi } from './ErrorApi'; @@ -52,7 +52,7 @@ export interface StorageApi { remove(key: string): Promise; /** - * Save persistant data, and emit messages to anyone that is using observe$ for this key + * Save persistent data, and emit messages to anyone that is using observe$ for this key * * @param {String} key Unique key associated with the data. */ @@ -65,7 +65,7 @@ export interface StorageApi { observe$(key: string): Observable>; } -export const storageApiRef = createApiRef({ +export const storageApiRef: ApiRef = createApiRef({ id: 'core.storage', description: 'Provides the ability to store data which is unique to the user', }); diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 109f15b26e..c538065f3a 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { createApiRef } from '../ApiRef'; -import { Observable } from '../..'; +import { ApiRef, createApiRef } from '../system'; +import { Observable } from '../../types'; /** * This file contains declarations for common interfaces of auth-related APIs. @@ -212,13 +212,13 @@ export type SessionApi = { * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, * email and expiration information. Do not rely on any other fields, as they might not be present. */ -export const googleAuthApiRef = createApiRef< +export const googleAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.google', description: 'Provides authentication towards Google APIs and identities', }); @@ -229,9 +229,9 @@ 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: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.github', description: 'Provides authentication towards GitHub APIs', }); @@ -242,13 +242,13 @@ export const githubAuthApiRef = createApiRef< * See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/ * for a full list of supported scopes. */ -export const oktaAuthApiRef = createApiRef< +export const oktaAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.okta', description: 'Provides authentication towards Okta APIs', }); @@ -259,9 +259,9 @@ export const oktaAuthApiRef = createApiRef< * See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token * for a full list of supported scopes. */ -export const gitlabAuthApiRef = createApiRef< +export const gitlabAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.gitlab', description: 'Provides authentication towards GitLab APIs', }); @@ -272,9 +272,9 @@ export const gitlabAuthApiRef = createApiRef< * See https://auth0.com/docs/scopes/current/oidc-scopes * for a full list of supported scopes. */ -export const auth0AuthApiRef = createApiRef< +export const auth0AuthApiRef: ApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.auth0', description: 'Provides authentication towards Auth0 APIs', }); @@ -286,13 +286,13 @@ export const auth0AuthApiRef = createApiRef< * - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent * - https://docs.microsoft.com/en-us/graph/permissions-reference */ -export const microsoftAuthApiRef = createApiRef< +export const microsoftAuthApiRef: ApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.microsoft', description: 'Provides authentication towards Microsoft APIs and identities', }); @@ -300,13 +300,13 @@ export const microsoftAuthApiRef = createApiRef< /** * Provides authentication for custom identity providers. */ -export const oauth2ApiRef = createApiRef< +export const oauth2ApiRef: ApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.oauth2', description: 'Example of how to use oauth2 custom provider', }); @@ -314,9 +314,20 @@ export const oauth2ApiRef = createApiRef< /** * Provides authentication for saml based identity providers */ -export const samlAuthApiRef = createApiRef< +export const samlAuthApiRef: ApiRef< ProfileInfoApi & BackstageIdentityApi & SessionApi ->({ +> = createApiRef({ id: 'core.auth.saml', description: 'Example of how to use SAML custom provider', }); + +export const oneloginAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.onelogin', + description: 'Provides authentication towards OneLogin APIs and identities', +}); diff --git a/packages/core-api/src/app/FeatureFlags.test.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx similarity index 59% rename from packages/core-api/src/app/FeatureFlags.test.tsx rename to packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx index 99974f9b1d..2902e399c9 100644 --- a/packages/core-api/src/app/FeatureFlags.test.tsx +++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx @@ -14,70 +14,52 @@ * limitations under the License. */ -import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags'; -import { FeatureFlagState, FeatureFlagsApi } from '../apis/definitions'; +import { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; +import { FeatureFlagState, FeatureFlagsApi } from '../../definitions'; describe('FeatureFlags', () => { beforeEach(() => { window.localStorage.clear(); }); - describe('#getFlags', () => { + describe('getFlags', () => { let featureFlags: FeatureFlagsApi; beforeEach(() => { - featureFlags = new FeatureFlagsImpl(); + featureFlags = new LocalStorageFeatureFlags(); }); it('returns no flags', () => { - expect(featureFlags.getFlags().toObject()).toMatchObject({}); + expect(featureFlags.getRegisteredFlags()).toEqual([]); }); - it('returns the correct flags', () => { + it('loads flags from local storage', () => { window.localStorage.setItem( 'featureFlags', JSON.stringify({ 'feature-flag-one': 1, 'feature-flag-two': 1, 'feature-flag-three': 0, + 'feature-flag-four': 2, + 'feature-flag-five': 'not-valid', }), ); - featureFlags = new FeatureFlagsImpl(); - expect(featureFlags.getFlags().toObject()).toMatchObject({ - 'feature-flag-one': FeatureFlagState.On, - 'feature-flag-two': FeatureFlagState.On, - 'feature-flag-three': FeatureFlagState.Off, - }); - }); - - it('gets the correct values', () => { - window.localStorage.setItem( - 'featureFlags', - JSON.stringify({ - 'feature-flag-one': 1, - 'feature-flag-two': 0, - }), - ); - - featureFlags = new FeatureFlagsImpl(); - - expect(featureFlags.getFlags().get('feature-flag-one')).toEqual( - FeatureFlagState.On, - ); - expect(featureFlags.getFlags().get('feature-flag-two')).toEqual( - FeatureFlagState.Off, - ); - expect(featureFlags.getFlags().get('feature-flag-three')).toEqual( - FeatureFlagState.Off, - ); + expect(featureFlags.isActive('feature-flag-one')).toBe(true); + expect(featureFlags.isActive('feature-flag-two')).toBe(true); + expect(featureFlags.isActive('feature-flag-three')).toBe(false); + expect(featureFlags.isActive('feature-flag-four')).toBe(false); + expect(featureFlags.isActive('feature-flag-five')).toBe(false); }); it('sets the correct values', () => { - const flags = featureFlags.getFlags(); - flags.set('feature-flag-zero', FeatureFlagState.On); + featureFlags.save({ + states: { + 'feature-flag-zero': FeatureFlagState.Active, + }, + }); - expect(flags.get('feature-flag-zero')).toEqual(FeatureFlagState.On); + expect(featureFlags.isActive('feature-flag-zero')).toBe(true); expect(window.localStorage.getItem('featureFlags')).toEqual( '{"feature-flag-zero":1}', ); @@ -89,16 +71,20 @@ describe('FeatureFlags', () => { JSON.stringify({ 'feature-flag-one': 1, 'feature-flag-two': 0, + 'feature-flag-tree': 1, + 'feature-flag-four': 0, }), ); - featureFlags = new FeatureFlagsImpl(); - const flags = featureFlags.getFlags(); - flags.delete('feature-flag-one'); + featureFlags.save({ + states: { + 'feature-flag-one': FeatureFlagState.None, + 'feature-flag-two': FeatureFlagState.Active, + }, + }); - expect(flags.get('feature-flag-one')).toEqual(FeatureFlagState.Off); expect(window.localStorage.getItem('featureFlags')).toEqual( - '{"feature-flag-two":0}', + '{"feature-flag-two":1}', ); }); @@ -112,33 +98,65 @@ describe('FeatureFlags', () => { }), ); - const flags = featureFlags.getFlags(); - flags.clear(); + expect(featureFlags.isActive('feature-flag-one')).toBe(true); + expect(featureFlags.isActive('feature-flag-two')).toBe(true); + expect(featureFlags.isActive('feature-flag-three')).toBe(false); + + featureFlags.save({ states: {} }); + + expect(featureFlags.isActive('feature-flag-one')).toBe(false); + expect(featureFlags.isActive('feature-flag-two')).toBe(false); + expect(featureFlags.isActive('feature-flag-three')).toBe(false); - expect(flags.toObject()).toEqual({}); expect(window.localStorage.getItem('featureFlags')).toEqual('{}'); }); }); - describe('#getRegisteredFlags', () => { + describe('getRegisteredFlags', () => { let featureFlags: FeatureFlagsApi; beforeEach(() => { - featureFlags = new FeatureFlagsImpl(); - featureFlags.registeredFeatureFlags = [ - { name: 'registered-flag-1', pluginId: 'plugin-one' }, - { name: 'registered-flag-2', pluginId: 'plugin-one' }, - { name: 'registered-flag-3', pluginId: 'plugin-two' }, - ]; + featureFlags = new LocalStorageFeatureFlags(); + featureFlags.registerFlag({ + name: 'registered-flag-1', + pluginId: 'plugin-one', + }); + featureFlags.registerFlag({ + name: 'registered-flag-2', + pluginId: 'plugin-one', + }); + featureFlags.registerFlag({ + name: 'registered-flag-3', + pluginId: 'plugin-two', + }); }); it('should return an empty list', () => { - featureFlags.registeredFeatureFlags = []; - expect(featureFlags.getRegisteredFlags().toObject()).toEqual([]); + featureFlags = new LocalStorageFeatureFlags(); + expect(featureFlags.getRegisteredFlags()).toEqual([]); }); it('should return an valid list', () => { - expect(featureFlags.getRegisteredFlags().toObject()).toMatchObject([ + expect(featureFlags.getRegisteredFlags()).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); + }); + + it('should provide a copy of the list of flags', () => { + const flags = featureFlags.getRegisteredFlags(); + expect(flags).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); + flags.splice(2, 1); + expect(flags).toEqual([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + ]); + expect(featureFlags.getRegisteredFlags()).toEqual([ { name: 'registered-flag-1', pluginId: 'plugin-one' }, { name: 'registered-flag-2', pluginId: 'plugin-one' }, { name: 'registered-flag-3', pluginId: 'plugin-two' }, @@ -164,48 +182,9 @@ describe('FeatureFlags', () => { }); }); - it('should append the correct value', () => { - const flags = featureFlags.getRegisteredFlags(); - - flags.push({ - name: 'registered-flag-4', - pluginId: 'plugin-three', - }); - - expect(flags.toObject()).toMatchObject([ - { name: 'registered-flag-1', pluginId: 'plugin-one' }, - { name: 'registered-flag-2', pluginId: 'plugin-one' }, - { name: 'registered-flag-3', pluginId: 'plugin-two' }, - { name: 'registered-flag-4', pluginId: 'plugin-three' }, - ]); - }); - - it('should concat the correct values', () => { - const flags = featureFlags.getRegisteredFlags(); - const concatValues = flags.concat([ - { - name: 'registered-flag-4', - pluginId: 'plugin-three', - }, - { - name: 'registered-flag-5', - pluginId: 'plugin-four', - }, - ]); - - expect(concatValues).toMatchObject([ - { name: 'registered-flag-1', pluginId: 'plugin-one' }, - { name: 'registered-flag-2', pluginId: 'plugin-one' }, - { name: 'registered-flag-3', pluginId: 'plugin-two' }, - { name: 'registered-flag-4', pluginId: 'plugin-three' }, - { name: 'registered-flag-5', pluginId: 'plugin-four' }, - ]); - }); - it('throws an error if length is less than three characters', () => { - const flags = featureFlags.getRegisteredFlags(); expect(() => - flags.push({ + featureFlags.registerFlag({ name: 'ab', pluginId: 'plugin-three', }), @@ -213,9 +192,8 @@ describe('FeatureFlags', () => { }); it('throws an error if length is greater than 150 characters', () => { - const flags = featureFlags.getRegisteredFlags(); expect(() => - flags.push({ + featureFlags.registerFlag({ name: 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat', pluginId: 'plugin-three', @@ -224,9 +202,8 @@ describe('FeatureFlags', () => { }); it('throws an error if name does not start with a lowercase letter', () => { - const flags = featureFlags.getRegisteredFlags(); expect(() => - flags.push({ + featureFlags.registerFlag({ name: '123456789', pluginId: 'plugin-three', }), @@ -234,9 +211,8 @@ describe('FeatureFlags', () => { }); it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => { - const flags = featureFlags.getRegisteredFlags(); expect(() => - flags.push({ + featureFlags.registerFlag({ name: 'Invalid_Feature_Flag', pluginId: 'plugin-three', }), diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx new file mode 100644 index 0000000000..00b3eff906 --- /dev/null +++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FeatureFlagState, + FeatureFlagsApi, + FeatureFlag, + FeatureFlagsSaveOptions, +} from '../../definitions'; + +export function validateFlagName(name: string): void { + if (name.length < 3) { + throw new Error( + `The '${name}' feature flag must have a minimum length of three characters.`, + ); + } + + if (name.length > 150) { + throw new Error( + `The '${name}' feature flag must not exceed 150 characters.`, + ); + } + + if (!name.match(/^[a-z]+[a-z0-9-]+$/)) { + throw new Error( + `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` + + 'Examples: feature-flag-one, alpha, release-2020', + ); + } +} + +/** + * Create the FeatureFlags implementation based on the API. + */ +export class LocalStorageFeatureFlags implements FeatureFlagsApi { + private registeredFeatureFlags: FeatureFlag[] = []; + private flags?: Map; + + registerFlag(flag: FeatureFlag) { + validateFlagName(flag.name); + this.registeredFeatureFlags.push(flag); + } + + getRegisteredFlags(): FeatureFlag[] { + return this.registeredFeatureFlags.slice(); + } + + isActive(name: string): boolean { + if (!this.flags) { + this.flags = this.load(); + } + return this.flags.get(name) === FeatureFlagState.Active; + } + + save(options: FeatureFlagsSaveOptions): void { + if (!this.flags) { + this.flags = this.load(); + } + if (!options.merge) { + this.flags.clear(); + } + for (const [name, state] of Object.entries(options.states)) { + this.flags.set(name, state); + } + + const enabled = Array.from(this.flags.entries()).filter( + ([, state]) => state === FeatureFlagState.Active, + ); + window.localStorage.setItem( + 'featureFlags', + JSON.stringify(Object.fromEntries(enabled)), + ); + } + + private load(): Map { + try { + const jsonStr = window.localStorage.getItem('featureFlags'); + if (!jsonStr) { + return new Map(); + } + const json = JSON.parse(jsonStr) as unknown; + if (typeof json !== 'object' || json === null || Array.isArray(json)) { + return new Map(); + } + + const entries = Object.entries(json).filter(([name, value]) => { + validateFlagName(name); + return value === FeatureFlagState.Active; + }); + + return new Map(entries); + } catch { + return new Map(); + } + } +} diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts b/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts new file mode 100644 index 0000000000..33990584f3 --- /dev/null +++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags'; diff --git a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts index 40e537169c..b41e705726 100644 --- a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts +++ b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -16,20 +16,8 @@ import Auth0Icon from '@material-ui/icons/AcUnit'; import { auth0AuthApiRef } from '../../../definitions/auth'; -import { - OAuthRequestApi, - AuthProvider, - DiscoveryApi, -} from '../../../definitions'; import { OAuth2 } from '../oauth2'; - -type CreateOptions = { - discoveryApi: DiscoveryApi; - oauthRequestApi: OAuthRequestApi; - - environment?: string; - provider?: AuthProvider & { id: string }; -}; +import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'auth0', @@ -43,13 +31,14 @@ class Auth0Auth { environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions): typeof auth0AuthApiRef.T { + defaultScopes = ['openid', `email`, `profile`], + }: OAuthApiCreateOptions): typeof auth0AuthApiRef.T { return OAuth2.create({ discoveryApi, oauthRequestApi, provider, environment, - defaultScopes: ['openid', `email`, `profile`], + defaultScopes, }); } } 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 1d87ff3720..532bff3480 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -25,25 +25,13 @@ import { BackstageIdentity, AuthRequestOptions, } from '../../../definitions/auth'; -import { - OAuthRequestApi, - AuthProvider, - DiscoveryApi, -} from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { AuthSessionStore, StaticAuthSessionManager, } from '../../../../lib/AuthSessionManager'; import { Observable } from '../../../../types'; - -type CreateOptions = { - discoveryApi: DiscoveryApi; - oauthRequestApi: OAuthRequestApi; - - environment?: string; - provider?: AuthProvider & { id: string }; -}; +import { OAuthApiCreateOptions } from '../types'; export type GithubAuthResponse = { providerInfo: { @@ -67,7 +55,8 @@ class GithubAuth implements OAuthApi, SessionApi { environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions) { + defaultScopes = ['read:user'], + }: OAuthApiCreateOptions) { const connector = new DefaultAuthConnector({ discoveryApi, environment, @@ -89,7 +78,7 @@ class GithubAuth implements OAuthApi, SessionApi { const sessionManager = new StaticAuthSessionManager({ connector, - defaultScopes: new Set(['read:user']), + defaultScopes: new Set(defaultScopes), sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts index 9e2acd4537..8669dff022 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -16,20 +16,8 @@ import GitlabIcon from '@material-ui/icons/AcUnit'; import { gitlabAuthApiRef } from '../../../definitions/auth'; -import { - OAuthRequestApi, - AuthProvider, - DiscoveryApi, -} from '../../../definitions'; import { OAuth2 } from '../oauth2'; - -type CreateOptions = { - discoveryApi: DiscoveryApi; - oauthRequestApi: OAuthRequestApi; - - environment?: string; - provider?: AuthProvider & { id: string }; -}; +import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'gitlab', @@ -43,13 +31,14 @@ class GitlabAuth { environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions): typeof gitlabAuthApiRef.T { + defaultScopes = ['read_user'], + }: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T { return OAuth2.create({ discoveryApi, oauthRequestApi, provider, environment, - defaultScopes: ['read_user'], + defaultScopes, }); } } 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 7e84226508..fbc902cdd8 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -16,20 +16,8 @@ import GoogleIcon from '@material-ui/icons/AcUnit'; import { googleAuthApiRef } from '../../../definitions/auth'; -import { - OAuthRequestApi, - AuthProvider, - DiscoveryApi, -} from '../../../definitions'; import { OAuth2 } from '../oauth2'; - -type CreateOptions = { - discoveryApi: DiscoveryApi; - oauthRequestApi: OAuthRequestApi; - - environment?: string; - provider?: AuthProvider & { id: string }; -}; +import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'google', @@ -37,25 +25,26 @@ const DEFAULT_PROVIDER = { icon: GoogleIcon, }; +const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; + class GoogleAuth { static create({ discoveryApi, oauthRequestApi, environment = 'development', provider = DEFAULT_PROVIDER, - }: CreateOptions): typeof googleAuthApiRef.T { - const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; - + defaultScopes = [ + 'openid', + `${SCOPE_PREFIX}userinfo.email`, + `${SCOPE_PREFIX}userinfo.profile`, + ], + }: OAuthApiCreateOptions): typeof googleAuthApiRef.T { return OAuth2.create({ discoveryApi, oauthRequestApi, provider, environment, - defaultScopes: [ - 'openid', - `${SCOPE_PREFIX}userinfo.email`, - `${SCOPE_PREFIX}userinfo.profile`, - ], + defaultScopes, scopeTransform(scopes: string[]) { return scopes.map(scope => { if (scope === 'openid') { diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 39223e8e15..7ef78d19cf 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -22,3 +22,4 @@ export * from './okta'; export * from './saml'; export * from './auth0'; export * from './microsoft'; +export * from './onelogin'; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts index 241ac7b802..3e2711db3b 100644 --- a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -16,21 +16,8 @@ import MicrosoftIcon from '@material-ui/icons/AcUnit'; import { microsoftAuthApiRef } from '../../../definitions/auth'; - -import { - OAuthRequestApi, - AuthProvider, - DiscoveryApi, -} from '../../../definitions'; import { OAuth2 } from '../oauth2'; - -type CreateOptions = { - discoveryApi: DiscoveryApi; - oauthRequestApi: OAuthRequestApi; - - environment?: string; - provider?: AuthProvider & { id: string }; -}; +import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'microsoft', @@ -44,19 +31,20 @@ class MicrosoftAuth { provider = DEFAULT_PROVIDER, oauthRequestApi, discoveryApi, - }: CreateOptions): typeof microsoftAuthApiRef.T { + defaultScopes = [ + 'openid', + 'offline_access', + 'profile', + 'email', + 'User.Read', + ], + }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T { return OAuth2.create({ discoveryApi, oauthRequestApi, provider, environment, - defaultScopes: [ - 'openid', - 'offline_access', - 'profile', - 'email', - 'User.Read', - ], + defaultScopes, }); } } diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts index d088ca9798..73ad3dcf14 100644 --- a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -19,11 +19,6 @@ import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { Observable } from '../../../../types'; -import { - AuthProvider, - OAuthRequestApi, - DiscoveryApi, -} from '../../../definitions'; import { AuthRequestOptions, BackstageIdentity, @@ -36,19 +31,14 @@ import { BackstageIdentityApi, } from '../../../definitions/auth'; import { OAuth2Session } from './types'; +import { OAuthApiCreateOptions } from '../types'; type Options = { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; }; -type CreateOptions = { - discoveryApi: DiscoveryApi; - oauthRequestApi: OAuthRequestApi; - - environment?: string; - provider?: AuthProvider & { id: string }; - defaultScopes?: string[]; +type CreateOptions = OAuthApiCreateOptions & { scopeTransform?: (scopes: string[]) => string[]; }; diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts index 7e9ff77678..2df908e9fb 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -16,20 +16,8 @@ import OktaIcon from '@material-ui/icons/AcUnit'; import { oktaAuthApiRef } from '../../../definitions/auth'; -import { - OAuthRequestApi, - AuthProvider, - DiscoveryApi, -} from '../../../definitions'; import { OAuth2 } from '../oauth2'; - -type CreateOptions = { - discoveryApi: DiscoveryApi; - oauthRequestApi: OAuthRequestApi; - - environment?: string; - provider?: AuthProvider & { id: string }; -}; +import { OAuthApiCreateOptions } from '../types'; const DEFAULT_PROVIDER = { id: 'okta', @@ -55,13 +43,14 @@ class OktaAuth { environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions): typeof oktaAuthApiRef.T { + defaultScopes = ['openid', 'email', 'profile', 'offline_access'], + }: OAuthApiCreateOptions): typeof oktaAuthApiRef.T { return OAuth2.create({ discoveryApi, oauthRequestApi, provider, environment, - defaultScopes: ['openid', 'email', 'profile', 'offline_access'], + defaultScopes, scopeTransform(scopes) { return scopes.map(scope => { if (OKTA_OIDC_SCOPES.has(scope)) { diff --git a/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts new file mode 100644 index 0000000000..ff5c7c1990 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -0,0 +1,82 @@ +/* + * 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 OneLoginIcon from '@material-ui/icons/AcUnit'; +import { oneloginAuthApiRef } from '../../../definitions/auth'; +import { + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../../definitions'; +import { OAuth2 } from '../oauth2'; + +type CreateOptions = { + discoveryApi: DiscoveryApi; + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +const DEFAULT_PROVIDER = { + id: 'onelogin', + title: 'onelogin', + icon: OneLoginIcon, +}; + +const OIDC_SCOPES: Set = new Set([ + 'openid', + 'profile', + 'email', + 'phone', + 'address', + 'groups', + 'offline_access', +]); + +const SCOPE_PREFIX: string = 'onelogin.'; + +class OneLoginAuth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions): typeof oneloginAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes: ['openid', 'email', 'profile', 'offline_access'], + scopeTransform(scopes) { + return scopes.map(scope => { + if (OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(SCOPE_PREFIX)) { + return scope; + } + + return `${SCOPE_PREFIX}${scope}`; + }); + }, + }); + } +} + +export default OneLoginAuth; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts b/packages/core-api/src/apis/implementations/auth/onelogin/index.ts similarity index 91% rename from plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts rename to packages/core-api/src/apis/implementations/auth/onelogin/index.ts index 7071799805..1d163207db 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts +++ b/packages/core-api/src/apis/implementations/auth/onelogin/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './ResourceGrowthBarChartLegend'; +export { default as OneLoginAuth } from './OneLoginAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts index 973b402756..da5f082c9b 100644 --- a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -27,18 +27,12 @@ import { BackstageIdentityApi, SessionApi, } from '../../../definitions/auth'; -import { AuthProvider, DiscoveryApi } from '../../../definitions'; import { SamlSession } from './types'; import { AuthSessionStore, StaticAuthSessionManager, } from '../../../../lib/AuthSessionManager'; - -type CreateOptions = { - discoveryApi: DiscoveryApi; - environment?: string; - provider?: AuthProvider & { id: string }; -}; +import { AuthApiCreateOptions } from '../types'; export type SamlAuthResponse = { profile: ProfileInfo; @@ -56,7 +50,7 @@ class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, - }: CreateOptions) { + }: AuthApiCreateOptions) { const connector = new DirectAuthConnector({ discoveryApi, environment, diff --git a/packages/core-api/src/apis/implementations/auth/types.ts b/packages/core-api/src/apis/implementations/auth/types.ts new file mode 100644 index 0000000000..db3b718d25 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { AuthProvider, DiscoveryApi, OAuthRequestApi } from '../../definitions'; + +export type OAuthApiCreateOptions = AuthApiCreateOptions & { + oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; +}; + +export type AuthApiCreateOptions = { + discoveryApi: DiscoveryApi; + environment?: string; + provider?: AuthProvider & { id: string }; +}; diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts index 30aeb81d44..d0df2760ab 100644 --- a/packages/core-api/src/apis/implementations/index.ts +++ b/packages/core-api/src/apis/implementations/index.ts @@ -23,7 +23,8 @@ export * from './auth'; export * from './AlertApi'; export * from './AppThemeApi'; export * from './ConfigApi'; -export * from './ErrorApi'; export * from './DiscoveryApi'; +export * from './ErrorApi'; +export * from './FeatureFlagsApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-api/src/apis/index.ts b/packages/core-api/src/apis/index.ts index c856a13668..03569f9570 100644 --- a/packages/core-api/src/apis/index.ts +++ b/packages/core-api/src/apis/index.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -export { ApiProvider, useApi, useApiHolder } from './ApiProvider'; -export { ApiRegistry } from './ApiRegistry'; -export * from './ApiRef'; -export * from './types'; -export * from './helpers'; +export * from './system'; export * from './definitions'; export * from './implementations'; diff --git a/packages/core-api/src/apis/ApiAggregator.test.ts b/packages/core-api/src/apis/system/ApiAggregator.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiAggregator.test.ts rename to packages/core-api/src/apis/system/ApiAggregator.test.ts diff --git a/packages/core-api/src/apis/ApiAggregator.ts b/packages/core-api/src/apis/system/ApiAggregator.ts similarity index 93% rename from packages/core-api/src/apis/ApiAggregator.ts rename to packages/core-api/src/apis/system/ApiAggregator.ts index da49ee6488..1587a1d10b 100644 --- a/packages/core-api/src/apis/ApiAggregator.ts +++ b/packages/core-api/src/apis/system/ApiAggregator.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { ApiRef } from './ApiRef'; -import { ApiHolder } from './types'; +import { ApiRef, ApiHolder } from './types'; /** * An ApiHolder that queries multiple other holders from for diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.test.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiFactoryRegistry.test.ts rename to packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts diff --git a/packages/core-api/src/apis/ApiFactoryRegistry.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts similarity index 98% rename from packages/core-api/src/apis/ApiFactoryRegistry.ts rename to packages/core-api/src/apis/system/ApiFactoryRegistry.ts index 0d36e6c550..556f63589c 100644 --- a/packages/core-api/src/apis/ApiFactoryRegistry.ts +++ b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts @@ -15,12 +15,12 @@ */ import { + ApiRef, ApiFactoryHolder, ApiFactory, AnyApiRef, AnyApiFactory, } from './types'; -import { ApiRef } from './ApiRef'; type ApiFactoryScope = | 'default' // Default factories registered by core and plugins diff --git a/packages/core-api/src/apis/ApiProvider.test.tsx b/packages/core-api/src/apis/system/ApiProvider.test.tsx similarity index 100% rename from packages/core-api/src/apis/ApiProvider.test.tsx rename to packages/core-api/src/apis/system/ApiProvider.test.tsx diff --git a/packages/core-api/src/apis/ApiProvider.tsx b/packages/core-api/src/apis/system/ApiProvider.tsx similarity index 96% rename from packages/core-api/src/apis/ApiProvider.tsx rename to packages/core-api/src/apis/system/ApiProvider.tsx index 24610a1372..f2aa70244e 100644 --- a/packages/core-api/src/apis/ApiProvider.tsx +++ b/packages/core-api/src/apis/system/ApiProvider.tsx @@ -16,8 +16,7 @@ import React, { FC, createContext, useContext, ReactNode } from 'react'; import PropTypes from 'prop-types'; -import { ApiRef } from './ApiRef'; -import { ApiHolder, TypesToApiRefs } from './types'; +import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; import { ApiAggregator } from './ApiAggregator'; type ApiProviderProps = { diff --git a/packages/core-api/src/apis/ApiRef.test.ts b/packages/core-api/src/apis/system/ApiRef.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiRef.test.ts rename to packages/core-api/src/apis/system/ApiRef.test.ts diff --git a/packages/core-api/src/apis/ApiRef.ts b/packages/core-api/src/apis/system/ApiRef.ts similarity index 95% rename from packages/core-api/src/apis/ApiRef.ts rename to packages/core-api/src/apis/system/ApiRef.ts index 793c1e5386..0e1eb177c8 100644 --- a/packages/core-api/src/apis/ApiRef.ts +++ b/packages/core-api/src/apis/system/ApiRef.ts @@ -14,17 +14,13 @@ * limitations under the License. */ +import type { ApiRef } from './types'; + export type ApiRefConfig = { id: string; description: string; }; -export type ApiRef = { - id: string; - description: string; - T: T; -}; - class ApiRefImpl implements ApiRef { constructor(private readonly config: ApiRefConfig) { const valid = config.id diff --git a/packages/core-api/src/apis/ApiRegistry.test.ts b/packages/core-api/src/apis/system/ApiRegistry.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiRegistry.test.ts rename to packages/core-api/src/apis/system/ApiRegistry.test.ts diff --git a/packages/core-api/src/apis/ApiRegistry.ts b/packages/core-api/src/apis/system/ApiRegistry.ts similarity index 93% rename from packages/core-api/src/apis/ApiRegistry.ts rename to packages/core-api/src/apis/system/ApiRegistry.ts index 64d97ba234..b105633c51 100644 --- a/packages/core-api/src/apis/ApiRegistry.ts +++ b/packages/core-api/src/apis/system/ApiRegistry.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { ApiRef } from './ApiRef'; -import { ApiHolder } from './types'; +import { ApiRef, ApiHolder } from './types'; type ApiImpl = readonly [ApiRef, T]; @@ -28,7 +27,7 @@ class ApiRegistryBuilder { } build(): ApiRegistry { - // eslint-disable-next-line no-use-before-define + // eslint-disable-next-line @typescript-eslint/no-use-before-define return new ApiRegistry(new Map(this.apis)); } } diff --git a/packages/core-api/src/apis/ApiResolver.test.ts b/packages/core-api/src/apis/system/ApiResolver.test.ts similarity index 100% rename from packages/core-api/src/apis/ApiResolver.test.ts rename to packages/core-api/src/apis/system/ApiResolver.test.ts diff --git a/packages/core-api/src/apis/ApiResolver.ts b/packages/core-api/src/apis/system/ApiResolver.ts similarity index 98% rename from packages/core-api/src/apis/ApiResolver.ts rename to packages/core-api/src/apis/system/ApiResolver.ts index cb65186a37..0dfeaeedd0 100644 --- a/packages/core-api/src/apis/ApiResolver.ts +++ b/packages/core-api/src/apis/system/ApiResolver.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { ApiRef } from './ApiRef'; import { + ApiRef, ApiHolder, ApiFactoryHolder, AnyApiRef, diff --git a/packages/core-api/src/apis/helpers.ts b/packages/core-api/src/apis/system/helpers.ts similarity index 93% rename from packages/core-api/src/apis/helpers.ts rename to packages/core-api/src/apis/system/helpers.ts index 7d616acd01..0ccd0cdb80 100644 --- a/packages/core-api/src/apis/helpers.ts +++ b/packages/core-api/src/apis/system/helpers.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { ApiFactory, TypesToApiRefs } from './types'; -import { ApiRef } from './ApiRef'; +import { ApiRef, ApiFactory, TypesToApiRefs } from './types'; /** * Used to infer types for a standalone ApiFactory that isn't immediately passed diff --git a/packages/core-api/src/apis/system/index.ts b/packages/core-api/src/apis/system/index.ts new file mode 100644 index 0000000000..10b2e0f084 --- /dev/null +++ b/packages/core-api/src/apis/system/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { ApiProvider, useApi, useApiHolder } from './ApiProvider'; +export { ApiRegistry } from './ApiRegistry'; +export { ApiResolver } from './ApiResolver'; +export { ApiFactoryRegistry } from './ApiFactoryRegistry'; +export { createApiRef } from './ApiRef'; +export * from './types'; +export * from './helpers'; diff --git a/packages/core-api/src/apis/types.ts b/packages/core-api/src/apis/system/types.ts similarity index 94% rename from packages/core-api/src/apis/types.ts rename to packages/core-api/src/apis/system/types.ts index 61c229b18e..b0c19e551c 100644 --- a/packages/core-api/src/apis/types.ts +++ b/packages/core-api/src/apis/system/types.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { ApiRef } from './ApiRef'; +export type ApiRef = { + id: string; + description: string; + T: T; +}; export type AnyApiRef = ApiRef; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 9dd2fb138f..9c38c20b44 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -30,7 +30,6 @@ import { SignInPageProps, } from './types'; import { BackstagePlugin } from '../plugin'; -import { FeatureFlagsRegistryItem } from './FeatureFlags'; import { featureFlagsApiRef, AppThemeApi, @@ -51,11 +50,11 @@ import { useApi, AnyApiFactory, ApiHolder, + LocalStorageFeatureFlags, } from '../apis'; import { useAsync } from 'react-use'; import { AppIdentity } from './AppIdentity'; -import { ApiFactoryRegistry } from '../apis/ApiFactoryRegistry'; -import { ApiResolver } from '../apis/ApiResolver'; +import { ApiResolver, ApiFactoryRegistry } from '../apis/system'; type FullAppOptions = { apis: Iterable; @@ -136,7 +135,8 @@ export class PrivateAppImpl implements BackstageApp { getRoutes(): JSX.Element[] { const routes = new Array(); - const registeredFeatureFlags = new Array(); + + const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!; const { NotFoundErrorPage } = this.components; @@ -172,9 +172,9 @@ export class PrivateAppImpl implements BackstageApp { break; } case 'feature-flag': { - registeredFeatureFlags.push({ - pluginId: plugin.getId(), + featureFlagsApi.registerFlag({ name: output.name, + pluginId: plugin.getId(), }); break; } @@ -184,11 +184,6 @@ export class PrivateAppImpl implements BackstageApp { } } - const featureFlags = this.getApiHolder().get(featureFlagsApiRef); - if (featureFlags) { - featureFlags.registeredFeatureFlags = registeredFeatureFlags; - } - routes.push(} />); return routes; @@ -320,6 +315,13 @@ export class PrivateAppImpl implements BackstageApp { factory: () => this.identityApi, }); + // It's possible to replace the feature flag API, but since we must have at least + // one implementation we add it here directly instead of through the defaultApis. + registry.register('default', { + api: featureFlagsApiRef, + deps: {}, + factory: () => new LocalStorageFeatureFlags(), + }); for (const factory of this.defaultApis) { registry.register('default', factory); } diff --git a/packages/core-api/src/app/FeatureFlags.tsx b/packages/core-api/src/app/FeatureFlags.tsx deleted file mode 100644 index 3db2a18a02..0000000000 --- a/packages/core-api/src/app/FeatureFlags.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { FeatureFlagName } from '../plugin/types'; -import { FeatureFlagState, FeatureFlagsApi } from '../apis/definitions'; - -/** - * Helper method for validating compatibility and flag name. - */ -export function validateBrowserCompat(): void { - if (!('localStorage' in window)) { - throw new Error( - 'Feature Flags are not supported on browsers without the Local Storage API', - ); - } -} - -export function validateFlagName(name: FeatureFlagName): void { - if (name.length < 3) { - throw new Error( - `The '${name}' feature flag must have a minimum length of three characters.`, - ); - } - - if (name.length > 150) { - throw new Error( - `The '${name}' feature flag must not exceed 150 characters.`, - ); - } - - if (!name.match(/^[a-z]+[a-z0-9-]+$/)) { - throw new Error( - `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` + - 'Examples: feature-flag-one, alpha, release-2020', - ); - } -} - -/** - * The UserFlags class. - * - * This acts as a data structure for the user's feature flags. You - * can use this to retrieve, add, edit, delete, clear and save the user's - * feature flags to the local browser for persisted storage. - */ -export class UserFlags extends Map { - static load(): UserFlags { - validateBrowserCompat(); - - try { - const jsonString = window.localStorage.getItem('featureFlags') as string; - const json = JSON.parse(jsonString); - return new this(Object.entries(json)); - } catch (err) { - return new this([]); - } - } - - get(name: FeatureFlagName): FeatureFlagState { - return super.get(name) || FeatureFlagState.Off; - } - - set(name: FeatureFlagName, state: FeatureFlagState): this { - validateFlagName(name); - const output = super.set(name, state); - this.save(); - return output; - } - - toggle(name: FeatureFlagName): FeatureFlagState { - if (super.get(name) === FeatureFlagState.On) { - super.set(name, FeatureFlagState.Off); - } else { - super.set(name, FeatureFlagState.On); - } - return super.get(name) || FeatureFlagState.Off; - } - - delete(name: FeatureFlagName): boolean { - const output = super.delete(name); - this.save(); - return output; - } - - clear(): void { - super.clear(); - this.save(); - } - - save(): void { - window.localStorage.setItem( - 'featureFlags', - JSON.stringify(this.toObject()), - ); - } - - toObject() { - return Array.from(this.entries()).reduce( - (obj, [key, value]) => ({ ...obj, [key]: value }), - {}, - ); - } - - toJSON() { - return JSON.stringify(this.toObject()); - } - - toString() { - return this.toJSON(); - } -} - -/** - * The FeatureFlagsRegistry class. - * - * This acts as a holding data structure for feature flags - * that plugins wish to register for use in Backstage. - */ -export interface FeatureFlagsRegistryItem { - pluginId: string; - name: FeatureFlagName; -} - -export class FeatureFlagsRegistry extends Array { - static from(entries: FeatureFlagsRegistryItem[]) { - Array.from(entries).forEach(entry => validateFlagName(entry.name)); - return new FeatureFlagsRegistry(...entries); - } - - push(...entries: FeatureFlagsRegistryItem[]): number { - Array.from(entries).forEach(entry => validateFlagName(entry.name)); - return super.push(...entries); - } - - concat( - ...entries: ( - | FeatureFlagsRegistryItem - | ConcatArray - )[] - ): FeatureFlagsRegistryItem[] { - const _concat = super.concat(...entries); - Array.from(_concat).forEach(entry => validateFlagName(entry.name)); - return _concat; - } - - toObject() { - return [...this.values()]; - } - - toJSON() { - return JSON.stringify(this.toObject()); - } - - toString() { - return this.toJSON(); - } -} - -/** - * Create the FeatureFlags implementation based on the API. - */ -export class FeatureFlags implements FeatureFlagsApi { - public registeredFeatureFlags: FeatureFlagsRegistryItem[] = []; - private userFlags: UserFlags | undefined; - - getFlags(): UserFlags { - if (!this.userFlags) this.userFlags = UserFlags.load(); - return this.userFlags; - } - - getRegisteredFlags(): FeatureFlagsRegistry { - return FeatureFlagsRegistry.from(this.registeredFeatureFlags); - } -} diff --git a/packages/core-api/src/app/index.ts b/packages/core-api/src/app/index.ts index 003b71e353..17610ea3ee 100644 --- a/packages/core-api/src/app/index.ts +++ b/packages/core-api/src/app/index.ts @@ -14,6 +14,5 @@ * limitations under the License. */ -export { FeatureFlags } from './FeatureFlags'; export { useApp } from './AppContext'; export * from './types'; diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 882ecd3d8b..8a1eb92e8e 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -112,11 +112,13 @@ export type AppOptions = { * title: 'Light Theme', * variant: 'light', * theme: lightTheme, + * icon: , * }, { * id: 'dark', * title: 'Dark Theme', * variant: 'dark', * theme: darkTheme, + * icon: , * }] * ``` */ diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index 5781130799..391b86952d 100644 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -19,8 +19,9 @@ import { DefaultAuthConnector } from './DefaultAuthConnector'; import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi'; import * as loginPopup from '../loginPopup'; import { UrlPatternDiscovery } from '../../apis'; - -const anyFetch = fetch as any; +import { msw } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; const defaultOptions = { discoveryApi: UrlPatternDiscovery.compile('http://my-host/api/{{pluginId}}'), @@ -39,19 +40,25 @@ const defaultOptions = { }; describe('DefaultAuthConnector', () => { + const server = setupServer(); + msw.setupDefaultHandlers(server); + afterEach(() => { jest.resetAllMocks(); - anyFetch.resetMocks(); }); it('should refresh a session', async () => { - anyFetch.mockResponseOnce( - JSON.stringify({ - idToken: 'mock-id-token', - accessToken: 'mock-access-token', - scopes: 'a b c', - expiresInSeconds: '60', - }), + server.use( + rest.get('*', (_req, res, ctx) => + res( + ctx.json({ + idToken: 'mock-id-token', + accessToken: 'mock-access-token', + scopes: 'a b c', + expiresInSeconds: '60', + }), + ), + ), ); const helper = new DefaultAuthConnector(defaultOptions); @@ -64,7 +71,11 @@ describe('DefaultAuthConnector', () => { }); it('should handle failure to refresh session', async () => { - anyFetch.mockRejectOnce(new Error('Network NOPE')); + server.use( + rest.get('*', (_req, res, ctx) => + res(ctx.status(500, 'Error: Network NOPE')), + ), + ); const helper = new DefaultAuthConnector(defaultOptions); await expect(helper.refreshSession()).rejects.toThrow( @@ -73,7 +84,7 @@ describe('DefaultAuthConnector', () => { }); it('should handle failure response when refreshing session', async () => { - anyFetch.mockResponseOnce({}, { status: 401, statusText: 'NOPE' }); + server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE')))); const helper = new DefaultAuthConnector(defaultOptions); await expect(helper.refreshSession()).rejects.toThrow( diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx index 1835ec03ad..d69bb7e721 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -14,50 +14,9 @@ * limitations under the License. */ -import { ComponentType } from 'react'; -import { - PluginOutput, - RoutePath, - RouteOptions, - FeatureFlagName, - BackstagePlugin, -} from './types'; -import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags'; -import { RouteRef } from '../routing'; +import { PluginConfig, PluginOutput, BackstagePlugin } from './types'; import { AnyApiFactory } from '../apis'; -export type PluginConfig = { - id: string; - apis?: Iterable; - register?(hooks: PluginHooks): void; -}; - -export type PluginHooks = { - router: RouterHooks; - featureFlags: FeatureFlagsHooks; -}; - -export type RouterHooks = { - addRoute( - target: RouteRef, - Component: ComponentType, - options?: RouteOptions, - ): void; - - /** - * @deprecated See the `addRoute` method - */ - registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, - ): void; -}; - -export type FeatureFlagsHooks = { - register(name: FeatureFlagName): void; -}; - export class PluginImpl { private storedOutput?: PluginOutput[]; @@ -97,8 +56,6 @@ export class PluginImpl { }, featureFlags: { register(name) { - validateBrowserCompat(); - validateFlagName(name); outputs.push({ type: 'feature-flag', name }); }, }, diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index 12992f3620..427d3d539a 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -16,7 +16,7 @@ import { ComponentType } from 'react'; import { RouteRef } from '../routing'; -import { AnyApiFactory } from '../apis'; +import { AnyApiFactory } from '../apis/system'; export type RouteOptions = { // Whether the route path must match exactly, defaults to true. @@ -54,11 +54,9 @@ export type LegacyRedirectRouteOutput = { options?: RouteOptions; }; -export type FeatureFlagName = string; - export type FeatureFlagOutput = { type: 'feature-flag'; - name: FeatureFlagName; + name: string; }; export type PluginOutput = @@ -73,3 +71,35 @@ export type BackstagePlugin = { output(): PluginOutput[]; getApis(): Iterable; }; + +export type PluginConfig = { + id: string; + apis?: Iterable; + register?(hooks: PluginHooks): void; +}; + +export type PluginHooks = { + router: RouterHooks; + featureFlags: FeatureFlagsHooks; +}; + +export type RouterHooks = { + addRoute( + target: RouteRef, + Component: ComponentType, + options?: RouteOptions, + ): void; + + /** + * @deprecated See the `addRoute` method + */ + registerRoute( + path: RoutePath, + Component: ComponentType, + options?: RouteOptions, + ): void; +}; + +export type FeatureFlagsHooks = { + register(name: string): void; +}; diff --git a/packages/core-api/src/setupTests.ts b/packages/core-api/src/setupTests.ts index 8553642152..aea2220869 100644 --- a/packages/core-api/src/setupTests.ts +++ b/packages/core-api/src/setupTests.ts @@ -15,5 +15,4 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); +import 'cross-fetch/polyfill'; diff --git a/packages/core-api/src/types.ts b/packages/core-api/src/types.ts index 09f0bc11ae..13a8a72570 100644 --- a/packages/core-api/src/types.ts +++ b/packages/core-api/src/types.ts @@ -15,7 +15,7 @@ */ /** - * This file contains non-react related core types used throught Backstage. + * This file contains non-react related core types used throughout Backstage. */ /** diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000000..213e2b5246 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,111 @@ +# @backstage/core + +## 0.3.1 + +### Patch Changes + +- 1722cb53c: Added configuration schema + +## 0.3.0 + +### Minor Changes + +- 199237d2f: New DependencyGraph component added to core package. + +### Patch Changes + +- 7b37d65fd: Adds the MarkdownContent component to render and display Markdown content with the default + [GFM](https://github.github.com/gfm/) (Github flavored Markdown) dialect. + + ``` + + ``` + + To render the Markdown content with plain [CommonMark](https://commonmark.org/), set the dialect to `common-mark` + + ``` + ...` + + Changed the InfoCards in '@backstage/plugin-github-actions', '@backstage/plugin-jenkins', '@backstage/plugin-lighthouse' + to pass an optional variant to the corresponding card of the plugin. + + As a result the overview content of the EntityPage shows cards with full height suitable for Grid. + +### Patch Changes + +- ae5983387: Fix banner position and color + + This PR closes: #2245 + + The "fixed" props added to control the position of the banner. When it is set to true the banner will be shown in bottom of that page and the width will be based on the content of the message. + + ![](https://user-images.githubusercontent.com/15106494/93765685-999df480-fc15-11ea-8fa5-11cac5836cf1.png) + + ![](https://user-images.githubusercontent.com/15106494/93765697-9e62a880-fc15-11ea-92af-b6a7fee4bb21.png) + +- 144c66d50: Fixed banner component position in DismissableBanner component +- 93a3fa3ae: Add forwardRef to the SidebarItem +- 782f3b354: add test case for Progress component +- 2713f28f4: fix the warning of all the core components test cases +- 406015b0d: Update ItemCard headers to pass color contrast standards. +- 82759d3e4: rename stories folder top Chip +- ac8d5d5c7: update the test cases of CodeSnippet component +- ebca83d48: add test cases for Status components +- aca79334f: update ItemCard component and it's story +- c0d5242a0: Proper render boolean values on StructuredMetadataTable component +- 3beb5c9fc: make ErrorPage responsive + fix the test case +- 754e31db5: give aria-label attribute to Status Ok, Warning and Error +- 1611c6dbc: fix the responsive of page story +- Updated dependencies [819a70229] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [cbbd271c4] +- Updated dependencies [b79017fd3] +- Updated dependencies [26e69ab1a] +- Updated dependencies [cbab5bbf8] + - @backstage/core-api@0.2.0 + - @backstage/theme@0.2.0 diff --git a/packages/core/README.md b/packages/core/README.md index a22f66ecd9..0d0063c9fd 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -18,5 +18,5 @@ $ yarn add @backstage/core ## Documentation -- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) -- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/core/config.d.ts b/packages/core/config.d.ts new file mode 100644 index 0000000000..014e4ac934 --- /dev/null +++ b/packages/core/config.d.ts @@ -0,0 +1,65 @@ +/* + * 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 interface Config { + /** + * Generic frontend configuration. + */ + app: { + /** + * The public absolute root URL that the frontend. + * @visibility frontend + */ + baseUrl: string; + + /** + * The title of the app. + * @visibility frontend + */ + title?: string; + }; + + /** + * Generic backend configuration. + */ + backend: { + /** + * The public absolute root URL that the backend is reachable at. + * @visibility frontend + */ + baseUrl: string; + }; + + /** + * Configuration that provides information about the organization that the app is for. + */ + organization?: { + /** + * The name of the organization that the app belongs to. + * @visibility frontend + */ + name?: string; + }; + + homepage?: { + clocks?: { + /** @visibility frontend */ + label: string; + /** @visibility frontend */ + timezone: string; + }[]; + }; +} diff --git a/packages/core/package.json b/packages/core/package.json index 3022aa29c4..70c1a9e647 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.24", + "version": "0.3.1", "private": false, "publishConfig": { "access": "public", @@ -11,7 +11,7 @@ "homepage": "https://backstage.io", "repository": { "type": "git", - "url": "https://github.com/spotify/backstage", + "url": "https://github.com/backstage/backstage", "directory": "packages/core" }, "keywords": [ @@ -29,16 +29,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.24", - "@backstage/core-api": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1", + "@backstage/core-api": "^0.2.1", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@types/dagre": "^0.7.44", "@types/react": "^16.9", "@types/react-sparklines": "^1.7.0", "classnames": "^2.2.6", "clsx": "^1.1.0", + "d3-selection": "^2.0.0", + "d3-shape": "^2.0.0", + "d3-zoom": "^2.0.0", + "dagre": "^0.8.5", + "qs": "^6.9.4", + "immer": "^7.0.9", "lodash": "^4.17.15", "material-table": "^1.69.1", "prop-types": "^15.7.2", @@ -47,27 +54,33 @@ "react-dom": "^16.12.0", "react-helmet": "6.1.0", "react-hook-form": "^6.6.0", + "react-markdown": "^5.0.2", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-sparklines": "^1.7.0", "react-syntax-highlighter": "^13.5.1", - "react-use": "^15.3.3" + "react-use": "^15.3.3", + "remark-gfm": "^1.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/test-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/classnames": "^2.2.9", + "@types/d3-selection": "^2.0.0", + "@types/d3-shape": "^2.0.0", + "@types/d3-zoom": "^2.0.0", "@types/google-protobuf": "^3.7.2", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react-helmet": "^6.1.0", - "@types/zen-observable": "^0.8.0", - "jest-fetch-mock": "^3.0.3" + "@types/zen-observable": "^0.8.0" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/packages/core/src/api-wrappers/createApp.test.tsx b/packages/core/src/api-wrappers/createApp.test.tsx index a1655b88dd..31f584b68c 100644 --- a/packages/core/src/api-wrappers/createApp.test.tsx +++ b/packages/core/src/api-wrappers/createApp.test.tsx @@ -18,10 +18,12 @@ import { defaultConfigLoader } from './createApp'; (process as any).env = { NODE_ENV: 'test' }; const anyEnv = process.env as any; +const anyWindow = window as any; describe('defaultConfigLoader', () => { afterEach(() => { delete anyEnv.APP_CONFIG; + delete anyWindow.__APP_CONFIG__; }); it('loads static config', async () => { @@ -73,4 +75,20 @@ describe('defaultConfigLoader', () => { 'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0', ); }); + + it('loads config from window.__APP_CONFIG__', async () => { + anyEnv.APP_CONFIG = [ + { data: { my: 'config' }, context: 'a' }, + { data: { my: 'override-config' }, context: 'b' }, + ]; + const windowConfig = { app: { configKey: 'config-value' } }; + anyWindow.__APP_CONFIG__ = windowConfig; + + const configs = await defaultConfigLoader(); + + expect(configs).toEqual([ + ...anyEnv.APP_CONFIG, + { context: 'window', data: windowConfig }, + ]); + }); }); diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index 7fe77423e2..4a1e58db42 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -22,7 +22,8 @@ import privateExports, { AppConfigLoader, } from '@backstage/core-api'; import { BrowserRouter, MemoryRouter } from 'react-router-dom'; - +import LightIcon from '@material-ui/icons/WbSunny'; +import DarkIcon from '@material-ui/icons/Brightness2'; import { ErrorPage } from '../layout/ErrorPage'; import { Progress } from '../components/Progress'; import { defaultApis } from './defaultApis'; @@ -60,12 +61,23 @@ export const defaultConfigLoader: AppConfigLoader = async ( if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) { try { const data = JSON.parse(runtimeConfigJson) as JsonObject; - configs.push({ data, context: 'env' }); + if (Array.isArray(data)) { + configs.push(...data); + } else { + configs.push({ data, context: 'env' }); + } } catch (error) { throw new Error(`Failed to load runtime configuration, ${error}`); } } + const windowAppConfig = (window as any).__APP_CONFIG__; + if (windowAppConfig) { + configs.push({ + context: 'window', + data: windowAppConfig, + }); + } return configs; }; @@ -110,12 +122,14 @@ export function createApp(options?: AppOptions) { title: 'Light Theme', variant: 'light', theme: lightTheme, + icon: , }, { id: 'dark', title: 'Dark Theme', variant: 'dark', theme: darkTheme, + icon: , }, ]; const configLoader = options?.configLoader ?? defaultConfigLoader; diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts index dedadf0456..61d4f3a7e1 100644 --- a/packages/core/src/api-wrappers/defaultApis.ts +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -20,8 +20,6 @@ import { AlertApiForwarder, ErrorApiForwarder, ErrorAlerter, - featureFlagsApiRef, - FeatureFlags, discoveryApiRef, GoogleAuth, GithubAuth, @@ -46,6 +44,8 @@ import { UrlPatternDiscovery, samlAuthApiRef, SamlAuth, + oneloginAuthApiRef, + OneLoginAuth, } from '@backstage/core-api'; export const defaultApis = [ @@ -69,7 +69,6 @@ export const defaultApis = [ deps: { errorApi: errorApiRef }, factory: ({ errorApi }) => WebStorage.create({ errorApi }), }), - createApiFactory(featureFlagsApiRef, new FeatureFlags()), createApiFactory(oauthRequestApiRef, new OAuthRequestManager()), createApiFactory({ api: googleAuthApiRef, @@ -96,7 +95,11 @@ export const defaultApis = [ oauthRequestApi: oauthRequestApiRef, }, factory: ({ discoveryApi, oauthRequestApi }) => - GithubAuth.create({ discoveryApi, oauthRequestApi }), + GithubAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user'], + }), }), createApiFactory({ api: oktaAuthApiRef, @@ -141,4 +144,13 @@ export const defaultApis = [ }, factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }), }), + createApiFactory({ + api: oneloginAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + OneLoginAuth.create({ discoveryApi, oauthRequestApi }), + }), ]; diff --git a/packages/core/src/components/Button/Button.stories.tsx b/packages/core/src/components/Button/Button.stories.tsx index aaca389f82..a5005574fe 100644 --- a/packages/core/src/components/Button/Button.stories.tsx +++ b/packages/core/src/components/Button/Button.stories.tsx @@ -24,7 +24,7 @@ const Location = () => { }; export default { - title: 'Button', + title: 'Inputs/Button', component: Button, decorators: [ (storyFn: FunctionComponentFactory<{}>) => ( diff --git a/packages/core/src/components/Button/Button.test.jsx b/packages/core/src/components/Button/Button.test.tsx similarity index 94% rename from packages/core/src/components/Button/Button.test.jsx rename to packages/core/src/components/Button/Button.test.tsx index 115835be3f..8bae5f2767 100644 --- a/packages/core/src/components/Button/Button.test.jsx +++ b/packages/core/src/components/Button/Button.test.tsx @@ -34,7 +34,9 @@ describe(' + +
+ {props.filters?.length && + props.filters.map(filter => + filter.type === 'checkbox-tree' ? ( + ({ + category: s, + }), + ) + : undefined + } + onChange={el => + setSelectedFilters({ + ...selectedFilters, + [filter.element.label]: el + .filter( + (checkboxFilter: any) => + checkboxFilter.category || + checkboxFilter.selectedChildren.length, + ) + .map((checkboxFilter: any) => + checkboxFilter.category + ? [ + ...checkboxFilter.selectedChildren, + checkboxFilter.category, + ] + : checkboxFilter.selectedChildren, + ) + .flat(), + }) + } + /> + ) : ( + - {currencies.map((c: Currency) => ( - - - {c.label} - - - ))} - + + Convert to: + + ); }; - -export default CurrencySelect; diff --git a/plugins/cost-insights/src/components/CurrencySelect/index.ts b/plugins/cost-insights/src/components/CurrencySelect/index.ts index 322b67d4ce..5e42217fe1 100644 --- a/plugins/cost-insights/src/components/CurrencySelect/index.ts +++ b/plugins/cost-insights/src/components/CurrencySelect/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './CurrencySelect'; +export { CurrencySelect } from './CurrencySelect'; diff --git a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx index 9783fadcb2..3442de49fc 100644 --- a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx @@ -17,9 +17,9 @@ import React from 'react'; import { Box, Typography } from '@material-ui/core'; import { CodeSnippet } from '@backstage/core'; -import AlertInstructionsLayout from '../AlertInstructionsLayout'; +import { AlertInstructionsLayout } from '../AlertInstructionsLayout'; -const LabelDataflowInstructionsPage = () => { +export const LabelDataflowInstructionsPage = () => { return ( Labeling Dataflow Jobs @@ -92,5 +92,3 @@ sc.optionsAs[DataflowPipelineOptions].setLabels(Map("job-id" -> "my-dataflow-job ); }; - -export default LabelDataflowInstructionsPage; diff --git a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts index 052ed379ec..de2136f033 100644 --- a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './LabelDataflowInstructionsPage'; +export { LabelDataflowInstructionsPage } from './LabelDataflowInstructionsPage'; diff --git a/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx b/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx index dd87ecc8a1..9424c50bf7 100644 --- a/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx +++ b/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx @@ -14,25 +14,24 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { Box, Typography, Tooltip } from '@material-ui/core'; import LensIcon from '@material-ui/icons/Lens'; import HelpOutlineOutlinedIcon from '@material-ui/icons/HelpOutlineOutlined'; import { useCostGrowthLegendStyles } from '../../utils/styles'; -type LegendItemProps = { +export type LegendItemProps = { title: string; tooltipText?: string; markerColor?: string; - children?: React.ReactNode; }; -const LegendItem = ({ +export const LegendItem = ({ title, tooltipText, markerColor, children, -}: LegendItemProps) => { +}: PropsWithChildren) => { const classes = useCostGrowthLegendStyles(); return ( @@ -74,5 +73,3 @@ const LegendItem = ({ ); }; - -export default LegendItem; diff --git a/plugins/cost-insights/src/components/LegendItem/index.ts b/plugins/cost-insights/src/components/LegendItem/index.ts index 763b8fc4f4..e97af0c97f 100644 --- a/plugins/cost-insights/src/components/LegendItem/index.ts +++ b/plugins/cost-insights/src/components/LegendItem/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './LegendItem'; +export { LegendItem } from './LegendItem'; diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx index 67d1ec3ad3..bfb6daedd6 100644 --- a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx @@ -16,14 +16,14 @@ import React from 'react'; import { waitFor } from '@testing-library/react'; import UserEvent from '@testing-library/user-event'; -import MetricSelect, { MetricSelectProps } from './MetricSelect'; +import { MetricSelect, MetricSelectProps } from './MetricSelect'; import { renderInTestApp } from '@backstage/test-utils'; describe('', () => { it('should display a metric', async () => { const mockProps: MetricSelectProps = { metric: 'test', - metrics: [{ kind: 'test', name: 'some-name' }], + metrics: [{ kind: 'test', name: 'some-name', default: false }], onSelect: jest.fn(), }; const { getByText } = await renderInTestApp( @@ -32,25 +32,12 @@ describe('', () => { expect(getByText(/some-name/)).toBeInTheDocument(); }); - it('should display a null metric', async () => { - const mockProps: MetricSelectProps = { - metric: null, - metrics: [{ kind: null, name: 'billie-nullish' }], - onSelect: jest.fn(), - }; - const { getByText } = await renderInTestApp( - , - ); - expect(getByText(/billie-nullish/)).toBeInTheDocument(); - }); - it('should display all metrics', async () => { const mockProps: MetricSelectProps = { metric: null, metrics: [ - { kind: null, name: 'billie-nullish' }, - { kind: 'MAU1M', name: 'Cost Per Million MAU' }, - { kind: 'my-cool-metric', name: 'metric-mcmetric-face' }, + { kind: 'DAU', name: 'Daily Active Users', default: true }, + { kind: 'MSC', name: 'Monthly Subscribers', default: false }, ], onSelect: jest.fn(), }; @@ -61,11 +48,10 @@ describe('', () => { UserEvent.click(button); - await waitFor(() => getAllByText(/billie-nullish/)); + await waitFor(() => getAllByText(/None/)); // The active metric should display in the popver list and in the input - expect(getAllByText(/billie-nullish/).length).toBe(2); - expect(getByText(/Cost Per Million MAU/)).toBeInTheDocument(); - expect(getByText(/metric-mcmetric-face/)).toBeInTheDocument(); + expect(getByText(/Daily Active Users/)).toBeInTheDocument(); + expect(getByText(/Monthly Subscribers/)).toBeInTheDocument(); }); }); diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx index 1d3af911d4..a8197fd7e9 100644 --- a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx @@ -15,10 +15,9 @@ */ import React from 'react'; -import { Select, MenuItem } from '@material-ui/core'; -import { Maybe, Metric, findAlways } from '../../types'; +import { InputLabel, FormControl, Select, MenuItem } from '@material-ui/core'; +import { Maybe, Metric } from '../../types'; import { useSelectStyles as useStyles } from '../../utils/styles'; -import { NULL_METRIC } from '../../hooks/useConfig'; export type MetricSelectProps = { metric: Maybe; @@ -26,42 +25,43 @@ export type MetricSelectProps = { onSelect: (metric: Maybe) => void; }; -const MetricSelect = ({ metric, metrics, onSelect }: MetricSelectProps) => { +export const MetricSelect = ({ + metric, + metrics, + onSelect, +}: MetricSelectProps) => { const classes = useStyles(); - const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { - if (e.target.value === NULL_METRIC) { + function onChange(e: React.ChangeEvent<{ value: unknown }>) { + if (e.target.value === 'none') { onSelect(null); } else { onSelect(e.target.value as string); } - }; - - const renderValue = (value: unknown) => { - const kind = (value === NULL_METRIC ? null : value) as Maybe; - const { name } = findAlways(metrics, m => m.kind === kind); - return {name}; - }; + } return ( - + + None - ))} - + {metrics.map((m: Metric) => ( + + {m.name} + + ))} + + ); }; - -export default MetricSelect; diff --git a/plugins/cost-insights/src/components/MetricSelect/index.ts b/plugins/cost-insights/src/components/MetricSelect/index.ts index f68e5031c1..4be7e97e90 100644 --- a/plugins/cost-insights/src/components/MetricSelect/index.ts +++ b/plugins/cost-insights/src/components/MetricSelect/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './MetricSelect'; +export { MetricSelect } from './MetricSelect'; diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx index c632ebad34..a3519ff61b 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -16,33 +16,42 @@ import React from 'react'; import { getByRole, waitFor } from '@testing-library/react'; -import UserEvent from '@testing-library/user-event'; -import PeriodSelect, { DEFAULT_OPTIONS as options } from './PeriodSelect'; -import { Duration, getDefaultPageFilters, Group } from '../../types'; - import { renderInTestApp } from '@backstage/test-utils'; +import UserEvent from '@testing-library/user-event'; +import { PeriodSelect, getDefaultOptions } from './PeriodSelect'; +import { getDefaultPageFilters } from '../../utils/filters'; +import { MockBillingDateProvider } from '../../utils/tests'; +import { Group, Duration } from '../../types'; const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]); - -Date.now = jest.fn(() => new Date(Date.parse('2020-05-01')).valueOf()); +const lastCompleteBillingDate = '2020-05-01'; +const options = getDefaultOptions(lastCompleteBillingDate); describe('', () => { it('Renders without exploding', async () => { const rendered = await renderInTestApp( - , + + + , ); expect(rendered.getByTestId('period-select')).toBeInTheDocument(); }); it('Should display all costGrowth period options', async () => { const rendered = await renderInTestApp( - , + + + , ); const periodSelectContainer = rendered.getByTestId('period-select'); const button = getByRole(periodSelectContainer, 'button'); @@ -70,7 +79,11 @@ describe('', () => { : DefaultPageFilters.duration; const rendered = await renderInTestApp( - , + + , + , ); const periodSelect = rendered.getByTestId('period-select'); const button = getByRole(periodSelect, 'button'); diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx index 688e22452e..4908641d6f 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -16,41 +16,42 @@ import React from 'react'; import { MenuItem, Select, SelectProps } from '@material-ui/core'; +import { Duration } from '../../types'; import { formatLastTwoLookaheadQuarters, formatLastTwoMonths, } from '../../utils/formatters'; -import { Duration, findAlways } from '../../types'; +import { findAlways } from '../../utils/assert'; import { useSelectStyles as useStyles } from '../../utils/styles'; +import { useLastCompleteBillingDate } from '../../hooks'; export type PeriodOption = { value: Duration; label: string; }; -const LAST_6_MONTHS = 'Past 6 Months'; -const LAST_60_DAYS = 'Past 60 Days'; -const LAST_2_COMPLETED_MONTHS = formatLastTwoMonths(); -const LAST_2_LOOKAHEAD_QUARTERS = formatLastTwoLookaheadQuarters(); - -export const DEFAULT_OPTIONS: PeriodOption[] = [ - { - value: Duration.P90D, - label: LAST_6_MONTHS, - }, - { - value: Duration.P30D, - label: LAST_60_DAYS, - }, - { - value: Duration.P1M, - label: LAST_2_COMPLETED_MONTHS, - }, - { - value: Duration.P3M, - label: LAST_2_LOOKAHEAD_QUARTERS, - }, -]; +export function getDefaultOptions( + lastCompleteBillingDate: string, +): PeriodOption[] { + return [ + { + value: Duration.P90D, + label: 'Past 6 Months', + }, + { + value: Duration.P30D, + label: 'Past 60 Days', + }, + { + value: Duration.P1M, + label: formatLastTwoMonths(lastCompleteBillingDate), + }, + { + value: Duration.P3M, + label: formatLastTwoLookaheadQuarters(lastCompleteBillingDate), + }, + ]; +} type PeriodSelectProps = { duration: Duration; @@ -58,19 +59,22 @@ type PeriodSelectProps = { options?: PeriodOption[]; }; -const PeriodSelect = ({ +export const PeriodSelect = ({ duration, onSelect, - options = DEFAULT_OPTIONS, + options, }: PeriodSelectProps) => { const classes = useStyles(); + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const optionsOrDefault = + options ?? getDefaultOptions(lastCompleteBillingDate); const handleOnChange: SelectProps['onChange'] = e => { onSelect(e.target.value as Duration); }; const renderValue: SelectProps['renderValue'] = value => { - const option = findAlways(DEFAULT_OPTIONS, o => o.value === value); + const option = findAlways(optionsOrDefault, o => o.value === value); return {option.label}; }; @@ -83,7 +87,7 @@ const PeriodSelect = ({ renderValue={renderValue} data-testid="period-select" > - {options.map(option => ( + {optionsOrDefault.map(option => ( ); }; - -export default PeriodSelect; diff --git a/plugins/cost-insights/src/components/PeriodSelect/index.ts b/plugins/cost-insights/src/components/PeriodSelect/index.ts index 7bd70b3358..31ca2a649c 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/index.ts +++ b/plugins/cost-insights/src/components/PeriodSelect/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './PeriodSelect'; +export { PeriodSelect } from './PeriodSelect'; diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx index cc52012543..c5d39a5139 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -16,11 +16,11 @@ import React from 'react'; import { Box, Typography, Grid } from '@material-ui/core'; -import ProductInsightsCard from '../ProductInsightsCard'; +import { ProductInsightsCard } from '../ProductInsightsCard'; import { useConfig } from '../../hooks'; -const ProductInsights = ({}) => { - const { products } = useConfig(); +export const ProductInsights = ({}) => { + const config = useConfig(); return ( <> @@ -30,7 +30,7 @@ const ProductInsights = ({}) => { - {products.map(product => ( + {config.products.map(product => ( @@ -39,5 +39,3 @@ const ProductInsights = ({}) => { ); }; - -export default ProductInsights; diff --git a/plugins/cost-insights/src/components/ProductInsights/index.ts b/plugins/cost-insights/src/components/ProductInsights/index.ts index 0a20d3c322..84c2d410b0 100644 --- a/plugins/cost-insights/src/components/ProductInsights/index.ts +++ b/plugins/cost-insights/src/components/ProductInsights/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './ProductInsights'; +export { ProductInsights } from './ProductInsights'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx new file mode 100644 index 0000000000..4cccd638cb --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityDialog.tsx @@ -0,0 +1,189 @@ +/* + * 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 from 'react'; +import classnames from 'classnames'; +import { Table, TableColumn } from '@backstage/core'; +import { Dialog, IconButton, Typography } from '@material-ui/core'; +import { default as CloseButton } from '@material-ui/icons/Close'; +import { CostGrowthIndicator } from '../CostGrowth'; +import { costFormatter, formatPercent } from '../../utils/formatters'; +import { useEntityDialogStyles as useStyles } from '../../utils/styles'; +import { BarChartOptions, Entity } from '../../types'; + +function createRenderer(col: keyof RowData, classes: Record) { + return function render(rowData: {}): JSX.Element { + const row = rowData as RowData; + const rowStyles = classnames(classes.row, { + [classes.rowTotal]: row.id === 'total', + [classes.colFirst]: col === 'sku', + [classes.colLast]: col === 'ratio', + }); + + switch (col) { + case 'previous': + case 'current': + return ( + + {costFormatter.format(row[col])} + + ); + case 'ratio': + return ( + formatPercent(Math.abs(amount))} + /> + ); + default: + return {row.sku}; + } + }; +} + +// material-table does not support fixed rows. Override the sorting algorithm +// to force Total row to bottom by default or when a user sort toggles a column. +function createSorter(field?: keyof Omit) { + return function rowSort(data1: {}, data2: {}): number { + const a = data1 as RowData; + const b = data2 as RowData; + if (a.id === 'total') return 1; + if (b.id === 'total') return 1; + if (field === 'sku') return a.sku.localeCompare(b.sku); + + return field + ? a[field] - b[field] + : b.previous + b.current - (a.previous - a.current); + }; +} + +const defaultEntity: Entity = { + id: null, + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + entities: [], +}; + +type RowData = { + id: string; + sku: string; + previous: number; + current: number; + ratio: number; +}; + +type ProductEntityDialogOptions = Partial< + Pick +>; + +type ProductEntityDialogProps = { + open: boolean; + entity?: Entity; + options?: ProductEntityDialogOptions; + onClose: () => void; +}; + +export const ProductEntityDialog = ({ + open, + entity = defaultEntity, + options = {}, + onClose, +}: ProductEntityDialogProps) => { + const classes = useStyles(); + + const data = Object.assign( + { + previousName: 'Previous', + currentName: 'Current', + }, + options, + ); + + const firstColClasses = classnames(classes.column, classes.colFirst); + const lastColClasses = classnames(classes.column, classes.colLast); + + const columns: TableColumn[] = [ + { + field: 'sku', + title: SKU, + render: createRenderer('sku', classes), + customSort: createSorter('sku'), + width: '33.33%', + }, + { + field: 'previous', + title: ( + {data.previousName} + ), + align: 'right', + render: createRenderer('previous', classes), + customSort: createSorter('previous'), + }, + { + field: 'current', + title: ( + {data.currentName} + ), + align: 'right', + render: createRenderer('current', classes), + customSort: createSorter('current'), + }, + { + field: 'ratio', + title: M/M, + align: 'right', + render: createRenderer('ratio', classes), + customSort: createSorter('ratio'), + }, + ]; + + const rowData: RowData[] = entity.entities + .map(e => ({ + id: e.id || 'Unknown', + sku: e.id || 'Unknown', + previous: e.aggregation[0], + current: e.aggregation[1], + ratio: e.change.ratio, + })) + .concat({ + id: 'total', + sku: 'Total', + previous: entity.aggregation[0], + current: entity.aggregation[1], + ratio: entity.change.ratio, + }) + .sort(createSorter()); + + return ( + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index 6d40fc276a..cdbbb79806 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -15,64 +15,33 @@ */ import React from 'react'; -import ProductInsightsCard from './ProductInsightsCard'; +import { renderInTestApp } from '@backstage/test-utils'; +import { ProductInsightsCard } from './ProductInsightsCard'; +import { CostInsightsApi } from '../../api'; import { - MockComputeEngine, createMockEntity, - mockDefaultState, - createMockProductCost, + mockDefaultLoadingState, + MockComputeEngine, + MockProductFilters, } from '../../utils/mockData'; import { - IdentityApi, - ApiRegistry, - identityApiRef, - ApiProvider, -} from '@backstage/core'; -import { costInsightsApiRef, CostInsightsApi } from '../../api'; -import { renderInTestApp } from '@backstage/test-utils'; -import { GroupsContext } from '../../hooks/useGroups'; -import { LoadingContext } from '../../hooks/useLoading'; -import { - Product, - ProductCost, - defaultCurrencies, - findAlways, -} from '../../types'; -import { + MockCostInsightsApiProvider, + MockBillingDateProvider, MockConfigProvider, - MockFilterProvider, MockCurrencyProvider, + MockFilterProvider, + MockGroupsProvider, MockScrollProvider, + MockLoadingProvider, } from '../../utils/tests'; +import { Duration, Entity, Product, ProductPeriod } from '../../types'; -const mockLoadingDispatch = jest.fn(); -const mockSetPageFilters = jest.fn(); -const mockSetProductFilters = jest.fn(); -const mockSetCurrency = jest.fn(); -const engineers = findAlways(defaultCurrencies, c => c.kind === null); - -const identityApi: Partial = { - getProfile: () => ({ - email: 'test-email@example.com', - displayName: 'User 1', - }), -}; - -const costInsightsApi = ( - productCost: ProductCost, -): Partial => ({ - getProductInsights: () => - Promise.resolve(productCost) as Promise, +const costInsightsApi = (entity: Entity): Partial => ({ + getProductInsights: () => Promise.resolve(entity), }); -const getApis = (productCost: ProductCost) => { - return ApiRegistry.from([ - [identityApiRef, identityApi], - [costInsightsApiRef, costInsightsApi(productCost)], - ]); -}; - -const mockProductCost = createMockProductCost(() => ({ +const mockProductCost = createMockEntity(() => ({ + id: 'test-id', entities: [], aggregation: [3000, 4000], change: { @@ -82,43 +51,33 @@ const mockProductCost = createMockProductCost(() => ({ })); const renderProductInsightsCardInTestApp = async ( - productCost: ProductCost, + entity: Entity, product: Product, + duration: Duration, ) => await renderInTestApp( - - - - - - - - - - - - - + + + + + + ({ + ...p, + duration: duration, + }))} + > + + + + + + + + + - , + , ); describe('', () => { @@ -126,6 +85,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( mockProductCost, MockComputeEngine, + Duration.P1M, ); expect( rendered.queryByTestId(`scroll-test-compute-engine`), @@ -133,27 +93,32 @@ describe('', () => { }); it('Should render the right subheader for products with cost data', async () => { - const productCost = { + const entity = { ...mockProductCost, entities: [...Array(1000)].map(createMockEntity), }; const rendered = await renderProductInsightsCardInTestApp( - productCost, + entity, MockComputeEngine, + Duration.P1M, ); const subheader = 'entities, sorted by cost'; - const subheaderRgx = new RegExp( - `${productCost.entities.length} ${subheader}`, - ); + const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`); expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); }); it('Should render the right subheader if there is no cost data or change data', async () => { - const productCost = { entities: [], aggregation: [0, 0] } as ProductCost; + const entity: Entity = { + id: 'test-id', + entities: [], + aggregation: [0, 0], + change: { ratio: 0, amount: 0 }, + }; const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`; const rendered = await renderProductInsightsCardInTestApp( - productCost, + entity, MockComputeEngine, + Duration.P1M, ); const subheaderRgx = new RegExp(subheader); expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); @@ -164,4 +129,27 @@ describe('', () => { rendered.queryByTestId('.insights-bar-chart'), ).not.toBeInTheDocument(); }); + + describe.each` + duration | periodStartText | periodEndText + ${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'} + ${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'} + `( + 'Should display the correct relative time', + ({ duration, periodStartText, periodEndText }) => { + it(`Should display the correct relative time for ${duration}`, async () => { + const entity = { + ...mockProductCost, + entities: [...Array(3)].map(createMockEntity), + }; + const rendered = await renderProductInsightsCardInTestApp( + entity, + MockComputeEngine, + duration, + ); + expect(rendered.getByText(periodStartText)).toBeInTheDocument(); + expect(rendered.getByText(periodEndText)).toBeInTheDocument(); + }); + }, + ); }); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 1d24139c64..2a1f962000 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -16,30 +16,35 @@ import React, { useCallback, useEffect, useState } from 'react'; import { InfoCard, useApi } from '@backstage/core'; -import { Box } from '@material-ui/core'; import Alert from '@material-ui/lab/Alert'; +import { Typography } from '@material-ui/core'; import { costInsightsApiRef } from '../../api'; -import PeriodSelect from '../PeriodSelect'; -import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; -import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; -import { useFilters, useLoading, useScroll } from '../../hooks'; +import { ProductInsightsChart } from './ProductInsightsChart'; +import { PeriodSelect } from '../PeriodSelect'; +import { + useFilters, + useLastCompleteBillingDate, + useLoading, + useScroll, +} from '../../hooks'; import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; import { mapFiltersToProps, mapLoadingToProps } from './selector'; -import { Duration, Maybe, Product, ProductCost } from '../../types'; +import { Duration, Maybe, Product, Entity } from '../../types'; import { pluralOf } from '../../utils/grammar'; type ProductInsightsCardProps = { product: Product; }; -const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { +export const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { const client = useApi(costInsightsApiRef); const classes = useStyles(); const { ScrollAnchor } = useScroll(product.kind); - const [resource, setResource] = useState>(null); + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const [entity, setEntity] = useState>(null); const [error, setError] = useState>(null); - const { group, product: productFilter, setProduct } = useFilters( + const { group, product: productFilter, setProduct, project } = useFilters( mapFiltersToProps(product.kind), ); const { loadingProduct, dispatchLoading } = useLoading( @@ -50,26 +55,25 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]); - const amount = resource?.entities?.length || 0; - const hasCostsWithinTimeframe = resource?.change && amount; + const amount = entity?.entities?.length || 0; + const hasCostsWithinTimeframe = !!(entity?.change && amount); - const subheader = amount + const subheader = hasCostsWithinTimeframe ? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost` - : `There are no ${product.name} costs within this timeframe for your team's projects.`; - - const costStart = resource?.aggregation[0] || 0; - const costEnd = resource?.aggregation[1] || 0; + : null; useEffect(() => { async function load() { if (loadingProduct) { try { - const p: ProductCost = await client.getProductInsights( - product.kind, - group!, - productFilter!.duration, - ); - setResource(p); + const e: Entity = await client.getProductInsights({ + product: product.kind, + group: group!, + duration: productFilter!.duration, + lastCompleteBillingDate, + project, + }); + setEntity(e); } catch (e) { setError(e); } finally { @@ -81,12 +85,14 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { }, [ client, product, - setResource, + setEntity, loadingProduct, dispatchLoadingProduct, productFilter, group, product.kind, + project, + lastCompleteBillingDate, ]); const onPeriodSelect = (duration: Duration) => { @@ -115,33 +121,25 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { ); } - if (!resource) { + if (!entity) { return null; } return ( - {hasCostsWithinTimeframe && ( - <> - - - - - - - + {hasCostsWithinTimeframe ? ( + + ) : ( + + There are no {product.name} costs within this timeframe for your + team's projects. + )} ); }; - -export default ProductInsightsCard; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx new file mode 100644 index 0000000000..6050dae9c7 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx @@ -0,0 +1,225 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { + ContentRenderer, + TooltipProps as RechartsTooltipProps, + RechartsFunction, +} from 'recharts'; +import { Box, Typography } from '@material-ui/core'; +import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen'; +import { LegendItem } from '../LegendItem'; +import { ProductEntityDialog } from './ProductEntityDialog'; +import { CostGrowth, CostGrowthIndicator } from '../CostGrowth'; +import { + BarChart, + BarChartLegend, + BarChartTooltip, + BarChartTooltipItem, +} from '../BarChart'; +import { pluralOf } from '../../utils/grammar'; +import { findAlways, notEmpty } from '../../utils/assert'; +import { formatPeriod, formatPercent } from '../../utils/formatters'; +import { + titleOf, + tooltipItemOf, + resourceOf, + isInvalid, + isActiveLabel, +} from '../../utils/graphs'; +import { + useProductInsightsChartStyles as useStyles, + useBarChartLayoutStyles as useLayoutStyles, +} from '../../utils/styles'; +import { BarChartOptions, Duration, Entity, Maybe } from '../../types'; + +export type ProductInsightsChartProps = { + billingDate: string; + entity: Entity; + duration: Duration; +}; + +export const ProductInsightsChart = ({ + billingDate, + entity, + duration, +}: ProductInsightsChartProps) => { + const classes = useStyles(); + const layoutClasses = useLayoutStyles(); + const [isOpen, setOpen] = useState(false); + const [isClickable, setClickable] = useState(true); + const [selectLabel, setSelected] = useState>(null); + const [activeLabel, setActive] = useState>(null); + + const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`; + const costStart = entity.aggregation[0]; + const costEnd = entity.aggregation[1]; + const resources = entity.entities.map(resourceOf); + + const options: Partial = { + previousName: formatPeriod(duration, billingDate, false), + currentName: formatPeriod(duration, billingDate, true), + }; + + useEffect(() => { + function toggleModal() { + if (selectLabel) { + setOpen(true); + } else { + setOpen(false); + } + } + + toggleModal(); + }, [selectLabel]); + + useEffect(() => { + // disable click if an entity is unlabeled or if it does not have any skus + function toggleClickableEntity() { + if (activeLabel) { + const hasSkus = entity.entities.find(e => e.id === activeLabel) + ?.entities.length; + if (hasSkus) { + setClickable(true); + } else { + setClickable(false); + } + } else { + setClickable(false); + } + } + + toggleClickableEntity(); + }, [activeLabel, entity]); + + const onMouseMove: RechartsFunction = ( + data: Record<'activeLabel', string | undefined>, + ) => { + if (isActiveLabel(data)) { + setActive(data.activeLabel!); + } else { + setActive(null); + } + }; + + const onClick: RechartsFunction = (data: Record<'activeLabel', string>) => { + if (isActiveLabel(data)) { + setSelected(data.activeLabel); + } else { + setSelected(null); + } + }; + + const renderProductInsightsTooltip: ContentRenderer = ({ + label, + payload = [], + }) => { + /* Labels and payloads may be undefined or empty */ + if (isInvalid({ label, payload })) return null; + + /* + * recharts coerces null values to strings + * entity -> resource -> payload + * { id: null } -> { name: null } -> { label: '' } + */ + const id = label === '' ? null : label; + + const title = titleOf(label); + const items = payload.map(tooltipItemOf).filter(notEmpty); + + const activeEntity = findAlways(entity.entities, e => e.id === id); + const ratio = activeEntity.change.ratio; + const skus = activeEntity.entities; + const subtitle = `${skus.length} ${pluralOf(skus.length, 'SKU')}`; + + if (skus.length) { + return ( + + } + actions={ + + + Click for breakdown + + } + > + {items.map((item, index) => ( + + ))} + + ); + } + + // If an entity doesn't have any skus, there aren't any costs to break down. + return ( + + } + content={ + id + ? null + : "This product has costs that are not labeled and therefore can't be attributed to a specific entity." + } + > + {items.map((item, index) => ( + + ))} + + ); + }; + + const barChartProps = isClickable ? { onClick } : {}; + + return ( + + + + + + + + {selectLabel && entity.entities.length && ( + setSelected(null)} + entity={entity.entities.find(e => e.id === selectLabel)} + options={options} + /> + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/index.ts b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts index ef378004f0..33a402234c 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/index.ts +++ b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export { default } from './ProductInsightsCard'; +export { ProductInsightsCard } from './ProductInsightsCard'; +export { ProductInsightsChart } from './ProductInsightsChart'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts index 001ff717bd..efc8448034 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts +++ b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts @@ -16,7 +16,8 @@ import { MapFiltersToProps } from '../../hooks/useFilters'; import { MapLoadingToProps } from '../../hooks/useLoading'; -import { Duration, PageFilters, ProductPeriod, findAlways } from '../../types'; +import { Duration, PageFilters, ProductPeriod } from '../../types'; +import { findAlways } from '../../utils/assert'; type ProductInsightsCardFilterProps = PageFilters & { product: ProductPeriod; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx index 5c3856cc9e..db3f2f9bf6 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -16,10 +16,16 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; -import ProjectGrowthAlertCard from './ProjectGrowthAlertCard'; -import { createMockProjectGrowthAlert } from '../../utils/mockData'; -import { MockCurrencyProvider, MockConfigProvider } from '../../utils/tests'; -import { AlertCost, defaultCurrencies, findAlways } from '../../types'; +import { ProjectGrowthAlertCard } from './ProjectGrowthAlertCard'; +import { createMockProjectGrowthData } from '../../utils/mockData'; +import { + MockCurrencyProvider, + MockConfigProvider, + MockBillingDateProvider, +} from '../../utils/tests'; +import { AlertCost } from '../../types'; +import { defaultCurrencies } from '../../utils/currency'; +import { findAlways } from '../../utils/assert'; const engineers = findAlways(defaultCurrencies, c => c.kind === null); @@ -29,8 +35,8 @@ const MockAlertCosts: AlertCost[] = [ { id: 'test-id-2', aggregation: [235, 400] }, ]; -const MockProjectGrowthAlert = createMockProjectGrowthAlert(alert => ({ - ...alert, +const MockProjectGrowthAlert = createMockProjectGrowthData(data => ({ + ...data, project: MockProject, products: MockAlertCosts, })); @@ -42,41 +48,36 @@ describe('', () => { ); const title = new RegExp(`Project growth for ${MockProject}`); const rendered = await renderInTestApp( - - - , - + + + + , + + , ); expect(rendered.getByText(title)).toBeInTheDocument(); expect(rendered.getByText(subheader)).toBeInTheDocument(); + // ISO 8601 quarter format (YYYY-QX) should be transformed to QX YYYY + expect(rendered.getByText('Q4 2019')).toBeInTheDocument(); + expect(rendered.getByText('Q1 2020')).toBeInTheDocument(); }); it('renders the correct title and subheader for a single service', async () => { const subheader = new RegExp('1 product'); const title = new RegExp(`Project growth for ${MockProject}`); const rendered = await renderInTestApp( - - - - + + + + + + , ); expect(rendered.getByText(title)).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx index c27b201202..bcda21e0af 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -15,20 +15,16 @@ */ import React from 'react'; -import { Box } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; -import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; -import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; -import { Duration, ProjectGrowthAlert } from '../../types'; +import { ProjectGrowthAlertChart } from './ProjectGrowthAlertChart'; +import { ProjectGrowthData } from '../../types'; import { pluralOf } from '../../utils/grammar'; type ProjectGrowthAlertProps = { - alert: ProjectGrowthAlert; + alert: ProjectGrowthData; }; -const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { - const [costStart, costEnd] = alert.aggregation; - +export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { const subheader = ` ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ alert.products.length > 1 ? ', sorted by cost' : '' @@ -39,22 +35,7 @@ const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { title={`Project growth for ${alert.project}`} subheader={subheader} > - - - - - - + ); }; - -export default ProjectGrowthAlertCard; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertChart.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertChart.tsx new file mode 100644 index 0000000000..53e52f18e8 --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertChart.tsx @@ -0,0 +1,55 @@ +/* + * 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 from 'react'; +import moment from 'moment'; +import { Box } from '@material-ui/core'; +import { BarChart, BarChartLegend } from '../BarChart'; +import { LegendItem } from '../LegendItem'; +import { CostGrowth } from '../CostGrowth'; +import { BarChartOptions, Duration, ProjectGrowthData } from '../../types'; +import { useBarChartLayoutStyles as useStyles } from '../../utils/styles'; +import { resourceOf } from '../../utils/graphs'; + +type ProjectGrowthAlertChartProps = { + alert: ProjectGrowthData; +}; + +export const ProjectGrowthAlertChart = ({ + alert, +}: ProjectGrowthAlertChartProps) => { + const classes = useStyles(); + + const costStart = alert.aggregation[0]; + const costEnd = alert.aggregation[1]; + const resourceData = alert.products.map(resourceOf); + + const options: Partial = { + previousName: moment(alert.periodStart, 'YYYY-[Q]Q').format('[Q]Q YYYY'), + currentName: moment(alert.periodEnd, 'YYYY-[Q]Q').format('[Q]Q YYYY'), + }; + + return ( + + + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts index ab608f5f4c..75656f105c 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './ProjectGrowthAlertCard'; +export { ProjectGrowthAlertCard } from './ProjectGrowthAlertCard'; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx index f8f4a90a9e..41b4ca90f6 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -15,23 +15,25 @@ */ import React from 'react'; +import moment from 'moment'; import { Box, Typography } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; -import AlertInstructionsLayout from '../AlertInstructionsLayout'; -import ProjectGrowthAlertCard from '../ProjectGrowthAlertCard'; +import { AlertInstructionsLayout } from '../AlertInstructionsLayout'; +import { ProductInsightsChart } from '../ProductInsightsCard'; import { - AlertType, + Alert, + DEFAULT_DATE_FORMAT, Duration, Entity, Product, - ProjectGrowthAlert, + ProjectGrowthData, } from '../../types'; -import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; -import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; +import { ProjectGrowthAlert } from '../../utils/alerts'; -const ProjectGrowthInstructionsPage = () => { - const projectGrowthAlert: ProjectGrowthAlert = { - id: AlertType.ProjectGrowth, +const today = moment().format(DEFAULT_DATE_FORMAT); + +export const ProjectGrowthInstructionsPage = () => { + const alertData: ProjectGrowthData = { project: 'example-project', periodStart: 'Q1 2020', periodEnd: 'Q2 2020', @@ -56,25 +58,41 @@ const ProjectGrowthInstructionsPage = () => { ], }; + const projectGrowthAlert: Alert = new ProjectGrowthAlert(alertData); + const product: Product = { kind: 'ComputeEngine', name: 'Compute Engine', }; - const entities: Entity[] = [ - { - id: 'service-one', - aggregation: [18200, 58500], + const entity: Entity = { + id: 'example-id', + aggregation: [20_000, 60_000], + change: { + ratio: 3, + amount: 40_000, }, - { - id: 'service-two', - aggregation: [1200, 1300], - }, - { - id: 'service-three', - aggregation: [600, 200], - }, - ]; + entities: [ + { + id: 'service-one', + aggregation: [18_200, 58_500], + entities: [], + change: { ratio: 2.21, amount: 40_300 }, + }, + { + id: 'service-two', + aggregation: [1200, 1300], + entities: [], + change: { ratio: 0.083, amount: 100 }, + }, + { + id: 'service-three', + aggregation: [600, 200], + entities: [], + change: { ratio: -0.666, amount: -400 }, + }, + ], + }; return ( @@ -135,7 +153,7 @@ const ProjectGrowthInstructionsPage = () => { comparison of cloud products over the examined time period: - + {projectGrowthAlert.element} This allows you to quickly see which cloud products contributed to the @@ -153,24 +171,12 @@ const ProjectGrowthInstructionsPage = () => { ) that has grown in cost: - {/* ProductInsightsCard without API query / PeriodSelect */} - - - - - - - - + @@ -208,5 +214,3 @@ const ProjectGrowthInstructionsPage = () => { ); }; - -export default ProjectGrowthInstructionsPage; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts index c75240405c..053289b2e2 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './ProjectGrowthInstructionsPage'; +export { ProjectGrowthInstructionsPage } from './ProjectGrowthInstructionsPage'; diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx index 92de819fe0..16270429e8 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { getByRole, waitFor } from '@testing-library/react'; import UserEvent from '@testing-library/user-event'; -import ProjectSelect from './ProjectSelect'; +import { ProjectSelect } from './ProjectSelect'; import { MockFilterProvider } from '../../utils/tests'; import { renderInTestApp } from '@backstage/test-utils'; @@ -27,20 +27,15 @@ const mockProjects = [ { id: 'project3' }, ]; -const mockSetPageFilters = jest.fn(); - describe('', () => { let Component: React.ReactNode; beforeEach(() => { Component = () => ( - + ); @@ -59,10 +54,9 @@ describe('', () => { const button = getByRole(projectSelectContainer, 'button'); UserEvent.click(button); await waitFor(() => rendered.getByTestId('option-all')); - mockProjects.forEach( - project => - project.id && - expect(rendered.getByText(project.id)).toBeInTheDocument(), + + mockProjects.forEach(project => + expect(rendered.getByText(project.id)).toBeInTheDocument(), ); }); }); diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx index cbcd8ede4e..ee4641a4eb 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -24,7 +24,11 @@ type ProjectSelectProps = { onSelect: (project: Maybe) => void; }; -const ProjectSelect = ({ project, projects, onSelect }: ProjectSelectProps) => { +export const ProjectSelect = ({ + project, + projects, + onSelect, +}: ProjectSelectProps) => { const classes = useStyles(); const projectOptions = [{ id: 'all' } as Project, ...projects] @@ -48,7 +52,7 @@ const ProjectSelect = ({ project, projects, onSelect }: ProjectSelectProps) => { ); }; - -export default ProjectSelect; diff --git a/plugins/cost-insights/src/components/ProjectSelect/index.ts b/plugins/cost-insights/src/components/ProjectSelect/index.ts index 47a2b9e22c..06a553444d 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/index.ts +++ b/plugins/cost-insights/src/components/ProjectSelect/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './ProjectSelect'; +export { ProjectSelect } from './ProjectSelect'; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx deleted file mode 100644 index 8355deab7d..0000000000 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import ResourceGrowthBarChart from './ResourceGrowthBarChart'; -import { Duration } from '../../types'; -import { renderInTestApp } from '@backstage/test-utils'; -import { createMockEntity } from '../../utils/mockData'; - -const MockResources = [...Array(10)].map((_, index) => - createMockEntity(() => ({ - id: `test-id-${index + 1}`, - // grow resource costs linearly for testing - aggregation: [index * 1000, (index + 1) * 1000], - })), -); - -describe('', () => { - it('Pre-renders without exploding', async () => { - const rendered = await renderInTestApp( - , - ); - expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument(); - }); -}); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx deleted file mode 100644 index 3ae35ba871..0000000000 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { TooltipPayload } from 'recharts'; -import { - currencyFormatter, - dateRegex, - formatDuration, -} from '../../utils/formatters'; -import { - BarChartData, - CostInsightsTheme, - DataKey, - Duration, - Entity, - inclusiveEndDateOf, - inclusiveStartDateOf, - Maybe, - ResourceData, - AlertCost, -} from '../../types'; -import BarChart from '../BarChart'; -import { TooltipItemProps } from '../Tooltip'; -import { useTheme } from '@material-ui/core'; - -export type ResourceGrowthBarChartProps = { - duration: Duration; - resources: Array; -}; - -const ResourceGrowthBarChart = ({ - duration, - resources, -}: ResourceGrowthBarChartProps) => { - const theme = useTheme(); - const getTooltipItem = (payload: TooltipPayload): Maybe => { - const label = dateRegex.test(payload.name) - ? formatDuration(payload.name, duration) - : payload.name; - const value = - typeof payload.value === 'number' - ? currencyFormatter.format(payload.value) - : (payload.value as string); - const fill = payload.fill as string; - - switch (payload.dataKey) { - case DataKey.Current: - case DataKey.Previous: - return { - label: label, - value: value, - fill: fill, - }; - default: - return null; - } - }; - - const barChartData: BarChartData = { - previousFill: theme.palette.lightBlue, - currentFill: theme.palette.darkBlue, - previousName: inclusiveStartDateOf(duration), - currentName: inclusiveEndDateOf(duration), - }; - - const resourceData: ResourceData[] = resources.map(resource => { - return { - name: resource.id, - previous: resource.aggregation[0], - current: resource.aggregation[1], - }; - }); - - return ( - - ); -}; - -export default ResourceGrowthBarChart; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx deleted file mode 100644 index 1e432ff3bc..0000000000 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { ReactNode } from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import ResourceGrowthBarChartLegend from './ResourceGrowthBarChartLegend'; -import { Currency, defaultCurrencies, Duration, findAlways } from '../../types'; -import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests'; - -const engineers = findAlways(defaultCurrencies, c => c.kind === null); - -const MockContext = ({ - children, - currency, -}: { - children: ReactNode; - currency: Currency; -}) => ( - - - {children} - - -); - -describe('', () => { - describe.each` - ratio | amount | costText | engineerTest - ${2.5} | ${300_000} | ${'Cost Growth'} | ${/\~6 engineers/} - ${-2.5} | ${-120_000} | ${'Cost Savings'} | ${/\~2 engineers/} - `( - 'Should display the cost text', - ({ ratio, amount, costText, engineerTest }) => { - it(`Should display the correct cost and engineer text for ${ratio} percent change`, async () => { - const rendered = await renderInTestApp( - - - , - ); - expect(rendered.getByText(costText)).toBeInTheDocument(); - expect(rendered.queryByText(engineerTest)).toBeInTheDocument(); - }); - }, - ); - - describe.each` - duration | periodStartText | periodEndText - ${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'} - ${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'} - `( - 'Should display the correct relative time', - ({ duration, periodStartText, periodEndText }) => { - it(`Should display the correct relative time for ${duration}`, async () => { - const rendered = await renderInTestApp( - - - , - ); - expect(rendered.getByText(periodStartText)).toBeInTheDocument(); - expect(rendered.getByText(periodEndText)).toBeInTheDocument(); - }); - }, - ); -}); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx deleted file mode 100644 index 1466e18ee1..0000000000 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Box, useTheme } from '@material-ui/core'; -import LegendItem from '../LegendItem'; -import CostGrowth from '../CostGrowth'; -import { currencyFormatter, formatDuration } from '../../utils/formatters'; -import { - ChangeStatistic, - CostInsightsTheme, - Duration, - inclusiveEndDateOf, - inclusiveStartDateOf, -} from '../../types'; - -export type ResourceGrowthBarChartLegendProps = { - change: ChangeStatistic; - duration: Duration; - costStart: number; - costEnd: number; -}; - -const ResourceGrowthBarChartLegend = ({ - change, - duration, - costStart, - costEnd, -}: ResourceGrowthBarChartLegendProps) => { - const theme = useTheme(); - - const startOf = inclusiveStartDateOf(duration); - const endOf = inclusiveEndDateOf(duration); - const periodStartTitle = formatDuration(startOf, duration); - const periodEndTitle = formatDuration(endOf, duration); - - return ( - - - - {currencyFormatter.format(costStart)} - - - - - {currencyFormatter.format(costEnd)} - - - - - - - ); -}; - -export default ResourceGrowthBarChartLegend; diff --git a/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx b/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx deleted file mode 100644 index 727b2476c8..0000000000 --- a/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Box, Typography } from '@material-ui/core'; -import TooltipItem, { TooltipItemProps } from './TooltipItem'; -import { useTooltipStyles } from '../../utils/styles'; - -export type TooltipProps = { - label?: string; - items?: Array; -}; - -const Tooltip = ({ label, items }: TooltipProps) => { - const classes = useTooltipStyles(); - return ( - - {label && ( - - {label} - - )} - {items && - items.map((item, index) => ( - - ))} - - ); -}; - -export default Tooltip; diff --git a/plugins/cost-insights/src/components/Tooltip/index.ts b/plugins/cost-insights/src/components/Tooltip/index.ts deleted file mode 100644 index ca20ab1242..0000000000 --- a/plugins/cost-insights/src/components/Tooltip/index.ts +++ /dev/null @@ -1,18 +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 { default } from './Tooltip'; -export * from './TooltipItem'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx index 6beb204b0d..df38c78a03 100644 --- a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx @@ -15,25 +15,25 @@ */ import React from 'react'; -import UnlabeledDataflowAlertCard from './UnlabeledDataflowAlertCard'; +import { UnlabeledDataflowAlertCard } from './UnlabeledDataflowAlertCard'; import { - createMockUnlabeledDataflowAlert, + createMockUnlabeledDataflowData, createMockUnlabeledDataflowAlertProject, } from '../../utils/mockData'; import { renderInTestApp } from '@backstage/test-utils'; -const MockUnlabeledDataflowAlertMultipleProjects = createMockUnlabeledDataflowAlert( - alert => ({ - ...alert, +const MockUnlabeledDataflowAlertMultipleProjects = createMockUnlabeledDataflowData( + data => ({ + ...data, projects: [...Array(10)].map(() => createMockUnlabeledDataflowAlertProject(), ), }), ); -const MockUnlabeledDataflowAlertSingleProject = createMockUnlabeledDataflowAlert( - alert => ({ - ...alert, +const MockUnlabeledDataflowAlertSingleProject = createMockUnlabeledDataflowData( + data => ({ + ...data, projects: [...Array(1)].map(() => createMockUnlabeledDataflowAlertProject(), ), diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx index 8dff85e867..e56b3b044e 100644 --- a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx @@ -15,37 +15,46 @@ */ import React from 'react'; -import { Box } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; -import UnlabeledDataflowBarChart from '../UnlabeledDataflowBarChart'; -import UnlabeledDataflowBarChartLegend from '../UnlabeledDataflowBarChartLegend'; -import { UnlabeledDataflowAlert } from '../../types'; +import { Box } from '@material-ui/core'; +import { BarChart, BarChartLegend } from '../BarChart'; +import { UnlabeledDataflowData, ResourceData } from '../../types'; import { pluralOf } from '../../utils/grammar'; +import { useBarChartLayoutStyles as useStyles } from '../../utils/styles'; type UnlabeledDataflowAlertProps = { - alert: UnlabeledDataflowAlert; + alert: UnlabeledDataflowData; }; -const UnlabeledDataflowAlertCard = ({ alert }: UnlabeledDataflowAlertProps) => { +export const UnlabeledDataflowAlertCard = ({ + alert, +}: UnlabeledDataflowAlertProps) => { + const classes = useStyles(); const projects = pluralOf(alert.projects.length, 'project'); const subheader = ` Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days. `; + const options = { + previousName: 'Unlabeled Cost', + currentName: 'Labeled Cost', + }; + + const resources: ResourceData[] = alert.projects.map(project => ({ + name: project.id, + previous: project.unlabeledCost, + current: project.labeledCost, + })); + return ( - - - - - - - + + + ); }; - -export default UnlabeledDataflowAlertCard; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts index 6f22abc062..11f7a6b6dd 100644 --- a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './UnlabeledDataflowAlertCard'; +export { UnlabeledDataflowAlertCard } from './UnlabeledDataflowAlertCard'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx deleted file mode 100644 index 7b1f4e31ec..0000000000 --- a/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { TooltipPayload } from 'recharts'; -import BarChart from '../BarChart'; -import { TooltipItemProps } from '../Tooltip'; -import { - BarChartData, - CostInsightsTheme, - ResourceData, - UnlabeledDataflowAlertProject, -} from '../../types'; -import { currencyFormatter } from '../../utils/formatters'; -import { useTheme } from '@material-ui/core'; - -type UnlabeledDataflowBarChartProps = { - projects: Array; -}; - -export const UnlabeledDataflowBarChart = ({ - projects, -}: UnlabeledDataflowBarChartProps) => { - const theme = useTheme(); - const barChartData: BarChartData = { - previousFill: theme.palette.lightBlue, - currentFill: theme.palette.darkBlue, - previousName: 'Unlabeled Cost', - currentName: 'Labeled Cost', - }; - - const getTooltipItem = (payload: TooltipPayload): TooltipItemProps => { - return { - label: payload.name, - value: - typeof payload.value === 'number' - ? currencyFormatter.format(payload.value) - : (payload.value as string), - fill: payload.fill as string, - }; - }; - - const resources: ResourceData[] = projects.map(project => { - return { - name: project.id, - previous: project.unlabeledCost || 0, - current: project.labeledCost || 0, - }; - }); - - return ( - - ); -}; - -export default UnlabeledDataflowBarChart; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx deleted file mode 100644 index 9ab4e90001..0000000000 --- a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import UnlabeledDataflowBarChartLegend from './UnlabeledDataflowBarChartLegend'; -import { renderInTestApp } from '@backstage/test-utils'; - -describe('', () => { - it('Displays the correct text', async () => { - const rendered = await renderInTestApp( - , - ); - expect(rendered.getByText('Total Unlabeled Cost')).toBeInTheDocument(); - expect(rendered.getByText('Total Labeled Cost')).toBeInTheDocument(); - expect(rendered.getByText('$9,843')).toBeInTheDocument(); - expect(rendered.getByText('$2,309')).toBeInTheDocument(); - }); -}); diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx deleted file mode 100644 index 68caaed6d0..0000000000 --- a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Box, useTheme } from '@material-ui/core'; -import LegendItem from '../LegendItem'; -import { currencyFormatter } from '../../utils/formatters'; -import { CostInsightsTheme } from '../../types'; - -type UnlabeledDataflowBarChartLegendProps = { - labeledCost: number; - unlabeledCost: number; -}; - -const UnlabeledDataflowBarChartLegend = ({ - unlabeledCost, - labeledCost, -}: UnlabeledDataflowBarChartLegendProps) => { - const theme = useTheme(); - return ( - - - - {currencyFormatter.format(unlabeledCost)} - - - - - {currencyFormatter.format(labeledCost)} - - - - ); -}; - -export default UnlabeledDataflowBarChartLegend; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts deleted file mode 100644 index 470722e60a..0000000000 --- a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/index.ts +++ /dev/null @@ -1,17 +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 { default } from './UnlabeledDataflowBarChartLegend'; diff --git a/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx b/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx index 69de854b00..18b5307d86 100644 --- a/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx +++ b/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Typography, Box, Grid, Container, Divider } from '@material-ui/core'; -const WhyCostsMatter = () => { +export const WhyCostsMatter = () => { return ( @@ -73,5 +73,3 @@ const WhyCostsMatter = () => { ); }; - -export default WhyCostsMatter; diff --git a/plugins/cost-insights/src/components/WhyCostsMatter/index.ts b/plugins/cost-insights/src/components/WhyCostsMatter/index.ts index ed60207fde..ef847d20aa 100644 --- a/plugins/cost-insights/src/components/WhyCostsMatter/index.ts +++ b/plugins/cost-insights/src/components/WhyCostsMatter/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './WhyCostsMatter'; +export { WhyCostsMatter } from './WhyCostsMatter'; diff --git a/plugins/cost-insights/src/components/index.ts b/plugins/cost-insights/src/components/index.ts new file mode 100644 index 0000000000..c390666419 --- /dev/null +++ b/plugins/cost-insights/src/components/index.ts @@ -0,0 +1,19 @@ +/* + * 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 * from './BarChart'; +export * from './CostGrowth'; +export * from './LegendItem'; diff --git a/plugins/cost-insights/src/hooks/index.ts b/plugins/cost-insights/src/hooks/index.ts index 7065bcf91b..94c556763c 100644 --- a/plugins/cost-insights/src/hooks/index.ts +++ b/plugins/cost-insights/src/hooks/index.ts @@ -20,5 +20,5 @@ export * from './useFilters'; export * from './useCurrency'; export * from './useGroups'; export * from './useLoading'; -export * from './useQueryParams'; export * from './useScroll'; +export * from './useLastCompleteBillingDate'; diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx index aee6567056..450ab545ac 100644 --- a/plugins/cost-insights/src/hooks/useConfig.tsx +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -15,22 +15,21 @@ */ import React, { - ReactNode, createContext, + PropsWithChildren, useContext, useEffect, useState, } from 'react'; -import { useApi, configApiRef } from '@backstage/core'; +import { configApiRef, useApi } from '@backstage/core'; import { Config as BackstageConfig } from '@backstage/config'; -import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types'; +import { Currency, Icon, Metric, Product } from '../types'; import { getIcon } from '../utils/navigation'; - -export const NULL_METRIC = 'dailyCost'; -export const NULL_METRIC_NAME = 'Daily Cost'; +import { validateMetrics } from '../utils/config'; +import { defaultCurrencies } from '../utils/currency'; /* - * Config schema 2020-09-28 + * Config schema 2020-10-15 * * costInsights: * engineerCost: 200000 @@ -44,6 +43,7 @@ export const NULL_METRIC_NAME = 'Daily Cost'; * metrics: * metricA: * name: Metric A + * default: true * metricB: * name: Metric B */ @@ -61,14 +61,14 @@ export const ConfigContext = createContext( ); const defaultState: ConfigContextProps = { - metrics: [{ kind: null, name: NULL_METRIC_NAME }], + metrics: [], products: [], icons: [], engineerCost: 0, currencies: defaultCurrencies, }; -export const ConfigProvider = ({ children }: { children: ReactNode }) => { +export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => { const c: BackstageConfig = useApi(configApiRef); const [config, setConfig] = useState(defaultState); const [loading, setLoading] = useState(true); @@ -87,8 +87,9 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { const metrics = c.getOptionalConfig('costInsights.metrics'); if (metrics) { return metrics.keys().map(key => ({ - kind: key === NULL_METRIC ? null : key, + kind: key, name: metrics.getString(`${key}.name`), + default: metrics.getOptionalBoolean(`${key}.default`) ?? false, })); } @@ -115,23 +116,16 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { const engineerCost = getEngineerCost(); const icons = getIcons(); - if (metrics.find((m: Metric) => m.kind === null)) { - setConfig(prevState => ({ - ...prevState, - metrics, - products, - engineerCost, - icons, - })); - } else { - setConfig(prevState => ({ - ...prevState, - metrics: [...prevState.metrics, ...metrics], - products, - engineerCost, - icons, - })); - } + validateMetrics(metrics); + + setConfig(prevState => ({ + ...prevState, + metrics, + products, + engineerCost, + icons, + })); + setLoading(false); } @@ -149,12 +143,7 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => { export function useConfig(): ConfigContextProps { const config = useContext(ConfigContext); - - if (!config) { - assertNever(); - } - - return config; + return config ? config : assertNever(); } function assertNever(): never { diff --git a/plugins/cost-insights/src/hooks/useCurrency.tsx b/plugins/cost-insights/src/hooks/useCurrency.tsx index f5d0df929a..ff41b033e4 100644 --- a/plugins/cost-insights/src/hooks/useCurrency.tsx +++ b/plugins/cost-insights/src/hooks/useCurrency.tsx @@ -16,26 +16,24 @@ import React, { Dispatch, SetStateAction, - ReactNode, useState, useContext, + PropsWithChildren, } from 'react'; -import { Currency, defaultCurrencies, findAlways } from '../types'; +import { Currency } from '../types'; +import { findAlways } from '../utils/assert'; +import { defaultCurrencies } from '../utils/currency'; export type CurrencyContextProps = { currency: Currency; setCurrency: Dispatch>; }; -export type CurrencyProviderProps = { - children: ReactNode; -}; - export const CurrencyContext = React.createContext< CurrencyContextProps | undefined >(undefined); -export const CurrencyProvider = ({ children }: CurrencyProviderProps) => { +export const CurrencyProvider = ({ children }: PropsWithChildren<{}>) => { const engineers = findAlways(defaultCurrencies, c => c.kind === null); const [currency, setCurrency] = useState(engineers); return ( diff --git a/plugins/cost-insights/src/hooks/useFilters.tsx b/plugins/cost-insights/src/hooks/useFilters.tsx index c2053d6b0f..22ef735186 100644 --- a/plugins/cost-insights/src/hooks/useFilters.tsx +++ b/plugins/cost-insights/src/hooks/useFilters.tsx @@ -16,112 +16,98 @@ import React, { Dispatch, - ReactNode, + PropsWithChildren, SetStateAction, useContext, useEffect, - useRef, useState, } from 'react'; -import { - getDefaultPageFilters, - PageFilters, - ProductFilters, - Duration, - Group, -} from '../types'; +import { Alert } from '@material-ui/lab'; +import { Maybe, PageFilters, ProductFilters } from '../types'; import { useLocation, useNavigate } from 'react-router-dom'; -import { useQueryParams } from './useQueryParams'; -import { stringify } from '../utils/history'; +import { + stringify, + validate, + getInitialPageState, + getInitialProductState, +} from '../utils/history'; import { useGroups } from './useGroups'; import { useConfig } from './useConfig'; -const getInitialPageState = ( - groups: Group[], - queryParams?: Partial, -) => { - // The group is written initially to queryParams as null, since user groups are asynchronously - // loaded. We preserve nulls in queryParams for other parameters where null is meaningful; for - // group, avoid overwriting the default with null after groups are loaded. - const { group, ...otherParams } = queryParams || {}; - return { - ...getDefaultPageFilters(groups), - ...otherParams, - ...(group ? { group: group } : {}), - }; -}; - export type FilterContextProps = { pageFilters: PageFilters; productFilters: ProductFilters; - setPageFilters: Dispatch>; - setProductFilters: Dispatch>; + setPageFilters: Dispatch>>; + setProductFilters: Dispatch>>; }; export type MapFiltersToProps = (props: FilterContextProps) => T; -export type FilterProviderProps = { - children: ReactNode; -}; - export const FilterContext = React.createContext< FilterContextProps | undefined >(undefined); -export const FilterProvider = ({ children }: FilterProviderProps) => { +export const FilterProvider = ({ children }: PropsWithChildren<{}>) => { + const config = useConfig(); const navigate = useNavigate(); const location = useLocation(); - const queryParams = useQueryParams(); - const qsRef = useRef(''); const groups = useGroups(); - const { products } = useConfig(); - - const defaultProductFilters = products.map(product => ({ - productType: product.kind, - duration: Duration.P1M, - })); - - const getInitialProductState = (productFilters?: ProductFilters) => { - if (!productFilters) return defaultProductFilters; - return defaultProductFilters.map(product => { - return ( - productFilters.find( - param => param.productType === product.productType, - ) || product - ); - }); - }; - - const [productFilters, setProductFilters] = useState( - getInitialProductState(queryParams.productFilters), - ); - const [pageFilters, setPageFilters] = useState( - getInitialPageState(groups, queryParams.pageFilters), + const [error, setError] = useState>(null); + const [pageFilters, setPageFilters] = useState>(null); + const [productFilters, setProductFilters] = useState>( + null, ); - // TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded. useEffect(() => { - setPageFilters(getInitialPageState(groups, queryParams.pageFilters)); + async function setPageFiltersFromLocation() { + try { + // strip extraneous parameters, validate and transform + const queryParams = await validate(location.search); + const defaultMetric = config.metrics.find(m => m.default)?.kind ?? null; + + // Group or project parameters should override defaults + const initialPageState = getInitialPageState(groups, queryParams); + const initialProductState = getInitialProductState(config); + + setProductFilters(initialProductState); + setPageFilters({ ...initialPageState, metric: defaultMetric }); + } catch (e) { + setError(e); + } + } + + setPageFiltersFromLocation(); }, [groups]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - const queryString = stringify({ ...pageFilters, products: productFilters }); - if (queryString === qsRef.current) return; - qsRef.current = queryString; - // TODO Remove workaround once issue is resolved in react-router - // (https://github.com/ReactTraining/react-router/issues/7496) - // navigate({ ...location, search: queryString }); - navigate({ ...location, search: `?${queryString}` }); - }, [pageFilters, productFilters]); // eslint-disable-line react-hooks/exhaustive-deps + function setLocationFromPageFilters(filters: PageFilters) { + const queryString = stringify({ + group: filters.group, + ...(filters.project ? { project: filters.project } : {}), + }); + // TODO Remove workaround once issue is resolved in react-router + // (https://github.com/ReactTraining/react-router/issues/7496) + // navigate({ ...location, search: queryString }); + navigate({ ...location, search: `?${queryString}` }); + } + + if (pageFilters) { + setLocationFromPageFilters(pageFilters); + } + }, [pageFilters]); // eslint-disable-line react-hooks/exhaustive-deps + + if (error) { + return {error.message}; + } + + // Wait for filters to load + if (!pageFilters || !productFilters) { + return null; + } return ( {children} @@ -130,17 +116,7 @@ export const FilterProvider = ({ children }: FilterProviderProps) => { export function useFilters(mapFiltersToProps: MapFiltersToProps): T { const context = useContext(FilterContext); - - if (!context) { - assertNever(); - } - - return mapFiltersToProps({ - pageFilters: context.pageFilters, - productFilters: context.productFilters, - setPageFilters: context.setPageFilters, - setProductFilters: context.setProductFilters, - }); + return context ? mapFiltersToProps(context) : assertNever(); } function assertNever(): never { diff --git a/plugins/cost-insights/src/hooks/useGroups.tsx b/plugins/cost-insights/src/hooks/useGroups.tsx index 02c32e7920..50444f2915 100644 --- a/plugins/cost-insights/src/hooks/useGroups.tsx +++ b/plugins/cost-insights/src/hooks/useGroups.tsx @@ -14,54 +14,69 @@ * limitations under the License. */ -import React, { ReactNode, useContext, useEffect, useState } from 'react'; +import React, { + PropsWithChildren, + useContext, + useEffect, + useState, +} from 'react'; +import { Alert } from '@material-ui/lab'; import { useApi, identityApiRef } from '@backstage/core'; import { costInsightsApiRef } from '../api'; import { MapLoadingToProps, useLoading } from './useLoading'; -import { DefaultLoadingAction, Group } from '../types'; +import { Group, Maybe } from '../types'; +import { DefaultLoadingAction } from '../utils/loading'; type GroupsProviderLoadingProps = { dispatchLoadingGroups: (isLoading: boolean) => void; }; -export const mapLoadingToProps: MapLoadingToProps = ({ +const mapLoadingToProps: MapLoadingToProps = ({ dispatch, }) => ({ dispatchLoadingGroups: (isLoading: boolean) => dispatch({ [DefaultLoadingAction.UserGroups]: isLoading }), }); -type GroupsContextProps = { +export type GroupsContextProps = { groups: Group[]; }; -export type GroupsProviderProps = { - children: ReactNode; -}; +export const GroupsContext = React.createContext< + GroupsContextProps | undefined +>(undefined); -export const GroupsContext = React.createContext({ - groups: [], -}); - -export const GroupsProvider = ({ children }: GroupsProviderProps) => { +export const GroupsProvider = ({ children }: PropsWithChildren<{}>) => { const userId = useApi(identityApiRef).getUserId(); const client = useApi(costInsightsApiRef); + const [error, setError] = useState>(null); const { dispatchLoadingGroups } = useLoading(mapLoadingToProps); - const [groups, setGroups] = useState([]); + const [groups, setGroups] = useState>(null); useEffect(() => { dispatchLoadingGroups(true); async function getUserGroups() { - const g = await client.getUserGroups(userId); - setGroups(g); - dispatchLoadingGroups(false); + try { + const g = await client.getUserGroups(userId); + setGroups(g); + } catch (e) { + setError(e); + } finally { + dispatchLoadingGroups(false); + } } getUserGroups(); }, [userId, client]); // eslint-disable-line react-hooks/exhaustive-deps + if (error) { + return {error.message}; + } + + if (!groups) return null; + return ( {children} @@ -70,13 +85,8 @@ export const GroupsProvider = ({ children }: GroupsProviderProps) => { }; export function useGroups(): Group[] { - const { groups } = useContext(GroupsContext); - - if (!groups) { - assertNever(); - } - - return groups; + const context = useContext(GroupsContext); + return context ? context.groups : assertNever(); } function assertNever(): never { diff --git a/plugins/cost-insights/src/hooks/useLastCompleteBillingDate.tsx b/plugins/cost-insights/src/hooks/useLastCompleteBillingDate.tsx new file mode 100644 index 0000000000..d2ee43980f --- /dev/null +++ b/plugins/cost-insights/src/hooks/useLastCompleteBillingDate.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, { + PropsWithChildren, + useContext, + useEffect, + useState, +} from 'react'; +import * as yup from 'yup'; +import { Alert } from '@material-ui/lab'; +import { useApi } from '@backstage/core'; +import { costInsightsApiRef } from '../api'; +import { MapLoadingToProps, useLoading } from './useLoading'; +import { DefaultLoadingAction } from '../utils/loading'; +import { Maybe } from '../types'; + +type BillingDateProviderLoadingProps = { + dispatchLoadingBillingDate: (isLoading: boolean) => void; +}; + +const mapLoadingToProps: MapLoadingToProps = ({ + dispatch, +}) => ({ + dispatchLoadingBillingDate: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.LastCompleteBillingDate]: isLoading }), +}); + +export type BillingDateContextProps = { + lastCompleteBillingDate: string; // YYYY-MM-DD +}; + +export const BillingDateContext = React.createContext< + BillingDateContextProps | undefined +>(undefined); + +export const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/; +const dateFormatSchema = yup.string().matches(dateRegex, { + message: + 'Unsupported billing date format: ${value}. Date should be in YYYY-MM-DD format.', + excludeEmptyString: true, +}); + +export const BillingDateProvider = ({ children }: PropsWithChildren<{}>) => { + const client = useApi(costInsightsApiRef); + const [error, setError] = useState>(null); + const { dispatchLoadingBillingDate } = useLoading(mapLoadingToProps); + + const [lastCompleteBillingDate, setLastCompeteBillingDate] = useState< + Maybe + >(null); + + useEffect(() => { + dispatchLoadingBillingDate(true); + + async function getLastCompleteBillingDate() { + try { + const d = await client.getLastCompleteBillingDate(); + const validDate = await dateFormatSchema.validate(d); + if (validDate) setLastCompeteBillingDate(validDate); + } catch (e) { + setError(e); + } finally { + dispatchLoadingBillingDate(false); + } + } + + getLastCompleteBillingDate(); + }, [client]); // eslint-disable-line react-hooks/exhaustive-deps + + if (error) { + return {error.message}; + } + + if (!lastCompleteBillingDate) return null; + + return ( + + {children} + + ); +}; + +export function useLastCompleteBillingDate(): string { + const context = useContext(BillingDateContext); + return context ? context.lastCompleteBillingDate : assertNever(); +} + +function assertNever(): never { + throw Error( + 'Cannot use useLastCompleteBillingDate outside of BillingDateProvider', + ); +} diff --git a/plugins/cost-insights/src/hooks/useLoading.tsx b/plugins/cost-insights/src/hooks/useLoading.tsx index bc0df4bbc7..420ac7ed56 100644 --- a/plugins/cost-insights/src/hooks/useLoading.tsx +++ b/plugins/cost-insights/src/hooks/useLoading.tsx @@ -15,10 +15,10 @@ */ import React, { - Dispatch, - ReactNode, - SetStateAction, createContext, + Dispatch, + PropsWithChildren, + SetStateAction, useContext, useEffect, useMemo, @@ -26,12 +26,12 @@ import React, { useState, } from 'react'; import { Backdrop, CircularProgress } from '@material-ui/core'; +import { Loading } from '../types'; import { - Loading, - getDefaultState, DefaultLoadingAction, + getDefaultState, getLoadingActions, -} from '../types'; +} from '../utils/loading'; import { useBackdropStyles as useStyles } from '../utils/styles'; import { useConfig } from './useConfig'; @@ -41,10 +41,6 @@ export type LoadingContextProps = { actions: Array; }; -export type LoadingProviderProps = { - children: ReactNode; -}; - export type MapLoadingToProps = (props: LoadingContextProps) => T; export const LoadingContext = createContext( @@ -58,7 +54,7 @@ function reducer(prevState: Loading, action: Partial): Loading { } as Record; } -export const LoadingProvider = ({ children }: LoadingProviderProps) => { +export const LoadingProvider = ({ children }: PropsWithChildren<{}>) => { const classes = useStyles(); const { products } = useConfig(); const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [ diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx index 480495a257..0a1abbdad3 100644 --- a/plugins/cost-insights/src/hooks/useScroll.tsx +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -15,12 +15,12 @@ */ import React, { Dispatch, - ReactNode, SetStateAction, useState, useContext, useEffect, useRef, + PropsWithChildren, } from 'react'; import { CSSProperties } from '@material-ui/styles'; import { Maybe } from '../types'; @@ -32,10 +32,6 @@ export type ScrollContextProps = { setScrollTo: Dispatch>; }; -export type ScrollProviderProps = { - children: ReactNode; -}; - export type ScrollUtils = { ScrollAnchor: (props: Omit) => JSX.Element; scrollIntoView: () => void; @@ -96,7 +92,7 @@ export const ScrollAnchor = ({ return
; }; -export const ScrollProvider = ({ children }: ScrollProviderProps) => { +export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => { const [scrollTo, setScrollTo] = useState(null); return ( diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index cf7aa3ea8c..28bdb1a8b3 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -15,5 +15,11 @@ */ export { plugin } from './plugin'; +export * from './client'; export * from './api'; +export * from './components'; +export { useCurrency } from './hooks'; export * from './types'; +export * from './utils/tests'; +export * from './utils/duration'; +export * from './utils/alerts'; diff --git a/plugins/cost-insights/src/plugin.ts b/plugins/cost-insights/src/plugin.ts index d8d54ae71b..58465f6711 100644 --- a/plugins/cost-insights/src/plugin.ts +++ b/plugins/cost-insights/src/plugin.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import CostInsightsPage from './components/CostInsightsPage'; -import ProjectGrowthInstructionsPage from './components/ProjectGrowthInstructionsPage'; -import LabelDataflowInstructionsPage from './components/LabelDataflowInstructionsPage'; +import { createPlugin, createRouteRef, PluginConfig } from '@backstage/core'; +import { CostInsightsPage } from './components/CostInsightsPage'; +import { ProjectGrowthInstructionsPage } from './components/ProjectGrowthInstructionsPage'; +import { LabelDataflowInstructionsPage } from './components/LabelDataflowInstructionsPage'; export const rootRouteRef = createRouteRef({ path: '/cost-insights', @@ -34,7 +34,7 @@ export const unlabeledDataflowAlertRef = createRouteRef({ title: 'Labeling Dataflow Jobs', }); -export const plugin = createPlugin({ +export const pluginConfig: PluginConfig = { id: 'cost-insights', register({ router, featureFlags }) { router.addRoute(rootRouteRef, CostInsightsPage); @@ -42,4 +42,6 @@ export const plugin = createPlugin({ router.addRoute(unlabeledDataflowAlertRef, LabelDataflowInstructionsPage); featureFlags.register('cost-insights-currencies'); }, -}); +}; + +export const plugin = createPlugin(pluginConfig); diff --git a/plugins/cost-insights/src/setupTests.ts b/plugins/cost-insights/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/cost-insights/src/setupTests.ts +++ b/plugins/cost-insights/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index 8d560096e3..c5c0463d99 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -13,19 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { ChangeStatistic } from './ChangeStatistic'; import { Maybe } from './Maybe'; -export type Alert = ProjectGrowthAlert | UnlabeledDataflowAlert; - -export interface AlertProps { - alert: Alert; -} - -export enum AlertType { - ProjectGrowth = 'projectGrowth', - UnlabeledDataflow = 'unlabeledDataflow', -} +/** + * Generic alert type with required fields for display. The `element` field will be rendered in + * the Cost Insights "Action Items" section. This should use data fetched in the CostInsightsApi + * implementation to render an InfoCard or other visualization. + */ +export type Alert = { + title: string; + subtitle: string; + url: string; + buttonText?: string; // Default: View Instructions + element: JSX.Element; +}; export interface AlertCost { id: string; @@ -38,21 +41,23 @@ export interface ResourceData { name: Maybe; } -export interface BarChartData { +export interface BarChartOptions { previousFill: string; currentFill: string; previousName: string; currentName: string; } +/** deprecated use BarChartOptions instead */ +export interface BarChartData extends BarChartOptions {} + export enum DataKey { Previous = 'previous', Current = 'current', Name = 'name', } -export interface ProjectGrowthAlert { - id: AlertType.ProjectGrowth; +export interface ProjectGrowthData { project: string; periodStart: string; periodEnd: string; @@ -61,8 +66,7 @@ export interface ProjectGrowthAlert { products: Array; } -export interface UnlabeledDataflowAlert { - id: AlertType.UnlabeledDataflow; +export interface UnlabeledDataflowData { periodStart: string; periodEnd: string; projects: Array; diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts index 538692bb1d..a47640411a 100644 --- a/plugins/cost-insights/src/types/ChangeStatistic.ts +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -28,21 +28,8 @@ export enum ChangeThreshold { lower = -0.05, } -export enum Growth { +export enum GrowthType { Negligible, Savings, Excess, } - -// Used by for displaying status colors -export function growthOf(amount: number, ratio: number) { - if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) { - return Growth.Excess; - } - - if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) { - return Growth.Savings; - } - - return Growth.Negligible; -} diff --git a/plugins/cost-insights/src/types/ChartData.tsx b/plugins/cost-insights/src/types/ChartData.tsx new file mode 100644 index 0000000000..d99e9d9bc3 --- /dev/null +++ b/plugins/cost-insights/src/types/ChartData.tsx @@ -0,0 +1,22 @@ +/* + * 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 type ChartData = { + date: number; + trend: number; + dailyCost: number; + [key: string]: number; +}; diff --git a/plugins/cost-insights/src/types/Currency.ts b/plugins/cost-insights/src/types/Currency.ts index 633e405893..8a8af81b0e 100644 --- a/plugins/cost-insights/src/types/Currency.ts +++ b/plugins/cost-insights/src/types/Currency.ts @@ -13,15 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Duration } from './Duration'; -import { assertNever } from './Maybe'; - -export enum CurrencyType { - USD = 'USD', - CarbonOffsetTons = 'CARBON_OFFSET_TONS', - Beers = 'BEERS', - IceCream = 'PINTS_OF_ICE_CREAM', -} export interface Currency { kind: string | null; @@ -31,48 +22,9 @@ export interface Currency { rate?: number; } -export const rateOf = (cost: number, duration: Duration) => { - switch (duration) { - case Duration.P1M: - case Duration.P30D: - return cost / 12; - case Duration.P90D: - case Duration.P3M: - return cost / 4; - default: - return assertNever(duration); - } -}; - -export const defaultCurrencies: Currency[] = [ - { - kind: null, - label: 'Engineers 🛠', - unit: 'engineer', - }, - { - kind: CurrencyType.USD, - label: 'US Dollars 💵', - unit: 'dollar', - prefix: '$', - rate: 1, - }, - { - kind: CurrencyType.CarbonOffsetTons, - label: 'Carbon Offset Tons ♻️⚖️s', - unit: 'carbon offset ton', - rate: 3.5, - }, - { - kind: CurrencyType.Beers, - label: 'Beers 🍺', - unit: 'beer', - rate: 4.5, - }, - { - kind: CurrencyType.IceCream, - label: 'Pints of Ice Cream 🍦', - unit: 'ice cream pint', - rate: 5.5, - }, -]; +export enum CurrencyType { + USD = 'USD', + CarbonOffsetTons = 'CARBON_OFFSET_TONS', + Beers = 'BEERS', + IceCream = 'PINTS_OF_ICE_CREAM', +} diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index 6e6c5b1100..acf707dd1e 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -14,9 +14,6 @@ * limitations under the License. */ -import moment from 'moment'; -import { assertNever } from './Maybe'; - /** * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P1M and P3M to mean * 'last completed [month|quarter]', and P30D/P90D to be '[month|quarter] relative to today'. So if @@ -31,55 +28,3 @@ export enum Duration { } export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; - -// Derive the start date of a given period, assuming two repeating intervals -export function inclusiveStartDateOf(duration: Duration): string { - switch (duration) { - case Duration.P30D: - case Duration.P90D: - return moment() - .utc() - .subtract(moment.duration(duration).add(moment.duration(duration))) - .format(DEFAULT_DATE_FORMAT); - case Duration.P1M: - return moment() - .utc() - .startOf('month') - .subtract(moment.duration(duration).add(moment.duration(duration))) - .format(DEFAULT_DATE_FORMAT); - case Duration.P3M: - return moment() - .utc() - .startOf('quarter') - .subtract(moment.duration(duration).add(moment.duration(duration))) - .format(DEFAULT_DATE_FORMAT); - default: - return assertNever(duration); - } -} - -export function exclusiveEndDateOf(duration: Duration): string { - switch (duration) { - case Duration.P30D: - case Duration.P90D: - return moment().utc().add(1, 'day').format(DEFAULT_DATE_FORMAT); - case Duration.P1M: - return moment().utc().startOf('month').format(DEFAULT_DATE_FORMAT); - case Duration.P3M: - return moment().utc().startOf('quarter').format(DEFAULT_DATE_FORMAT); - default: - return assertNever(duration); - } -} - -export function inclusiveEndDateOf(duration: Duration): string { - return moment(exclusiveEndDateOf(duration)) - .utc() - .subtract(1, 'day') - .format(DEFAULT_DATE_FORMAT); -} - -// https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals -export function intervalsOf(duration: Duration) { - return `R2/${duration}/${exclusiveEndDateOf(duration)}`; -} diff --git a/plugins/cost-insights/src/types/Entity.ts b/plugins/cost-insights/src/types/Entity.ts index f5ac620289..83347f05c3 100644 --- a/plugins/cost-insights/src/types/Entity.ts +++ b/plugins/cost-insights/src/types/Entity.ts @@ -20,5 +20,68 @@ import { Maybe } from './Maybe'; export interface Entity { id: Maybe; aggregation: [number, number]; - change?: Maybe; + entities: Entity[]; + change: ChangeStatistic; } + +/* + An entity is a tree-like structure that represents any unique + product or service that generates cost over a fixed period of time. + An entity could be atomic or composite. An atomic entity is indivisible + and cannot be broken into sub-entities. + + A composite entity can be broken down recursively into sub-entities + that generate cost **over the same time period**. All costs must sum to the root cost. + + Entities with null ids are considered "unlabeled" - costs without attribution. + If an entity is a composite, it may only have one (1) null child but may have any number of + null grandchildren. + + { + id: 'product', + aggregation: [0, 200], + change: { + ratio: 2000, + amount: 200 + }, + entities: [ + { + id: 'service-a', + aggregation: [0, 100], + change: { + ratio: 100, + amount: 100 + }, + entities: [] + }, + { + id: 'service-b', + aggregation: [0, 100], + change: { + ratio: 100, + amount: 100 + }, + entities: [ + { + id: 'service-b-sku-a', + aggregation: [0, 25], + change: { + ratio: 25, + amount: 25 + }, + entities: [] + }, + { + id: null, // Unlabeled cost for service-b + aggregation: [0, 75], + change: { + ratio: 75, + amount: 75 + }, + entities: [] + }, + ] + }, + ] + } +*/ diff --git a/plugins/cost-insights/src/types/Filters.ts b/plugins/cost-insights/src/types/Filters.ts index def1d74d1a..120738fcc7 100644 --- a/plugins/cost-insights/src/types/Filters.ts +++ b/plugins/cost-insights/src/types/Filters.ts @@ -16,29 +16,17 @@ import { Maybe } from './Maybe'; import { Duration } from './Duration'; -import { Group } from './Group'; export interface PageFilters { group: Maybe; project: Maybe; duration: Duration; - metric: Maybe; + metric: string | null; } export type ProductFilters = Array; -export type QueryParams = PageFilters & { products: ProductFilters }; - export interface ProductPeriod { duration: Duration; productType: string; } - -export function getDefaultPageFilters(groups: Group[]): PageFilters { - return { - group: groups.length ? groups[0].id : null, - project: null, - duration: Duration.P90D, - metric: null, - }; -} diff --git a/plugins/cost-insights/src/types/Loading.ts b/plugins/cost-insights/src/types/Loading.ts index f5da1d2b07..741453369c 100644 --- a/plugins/cost-insights/src/types/Loading.ts +++ b/plugins/cost-insights/src/types/Loading.ts @@ -15,49 +15,3 @@ */ export type Loading = Record; - -export enum DefaultLoadingAction { - UserGroups = 'user-groups', - CostInsightsInitial = 'cost-insights-initial', - CostInsightsPage = 'cost-insights-page', -} - -export const INITIAL_LOADING_ACTIONS = [ - DefaultLoadingAction.UserGroups, - DefaultLoadingAction.CostInsightsInitial, -]; - -export const getDefaultState = (loadingActions: string[]): Loading => { - return loadingActions.reduce( - (defaultState, action) => ({ ...defaultState, [action]: true }), - {}, - ); -}; - -export const getResetState = (loadingActions: string[]): Loading => { - return loadingActions.reduce( - (defaultState, action) => ({ ...defaultState, [action]: false }), - {}, - ); -}; - -export const getResetStateWithoutInitial = ( - loadingActions: string[], -): Loading => { - return loadingActions.reduce((defaultState, action) => { - const loadingActionState = (INITIAL_LOADING_ACTIONS as string[]).includes( - action, - ) - ? false - : true; - return { ...defaultState, [action]: loadingActionState }; - }, {}); -}; - -export function getLoadingActions(products: string[]): string[] { - return ([ - DefaultLoadingAction.UserGroups, - DefaultLoadingAction.CostInsightsInitial, - DefaultLoadingAction.CostInsightsPage, - ] as string[]).concat(products); -} diff --git a/plugins/cost-insights/src/types/Maybe.ts b/plugins/cost-insights/src/types/Maybe.ts index d721f28d75..a01a658382 100644 --- a/plugins/cost-insights/src/types/Maybe.ts +++ b/plugins/cost-insights/src/types/Maybe.ts @@ -13,33 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export type Maybe = T | null; - -export function notEmpty( - value: TValue | null | undefined, -): value is TValue { - return value !== null && value !== undefined; -} - -// Utility for exhaustiveness checking in switch statements -export function assertNever(x: never): never { - throw new Error(`Exhaustiveness check failed: ${x}`); -} - -export function assertAlways(argument: T | undefined): T { - if (argument === undefined) { - throw new TypeError( - 'Expected to always find a value but received undefined', - ); - } - return argument; -} - -// Utility for working with static lists; asserts a value will always be found or -// throws an error -export function findAlways( - collection: T[], - callback: (el: T) => boolean, -): T { - return assertAlways(collection.find(callback)); -} diff --git a/plugins/cost-insights/src/types/Metric.ts b/plugins/cost-insights/src/types/Metric.ts index 5a440a8d1f..b0a0c9cc76 100644 --- a/plugins/cost-insights/src/types/Metric.ts +++ b/plugins/cost-insights/src/types/Metric.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { Maybe } from './Maybe'; - export type Metric = { - kind: Maybe; + kind: string; name: string; + default: boolean; }; diff --git a/plugins/cost-insights/src/types/MetricData.ts b/plugins/cost-insights/src/types/MetricData.ts new file mode 100644 index 0000000000..bd3bdde735 --- /dev/null +++ b/plugins/cost-insights/src/types/MetricData.ts @@ -0,0 +1,25 @@ +/* + * 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 { DateAggregation } from './DateAggregation'; +import { ChangeStatistic } from './ChangeStatistic'; + +export interface MetricData { + id: string; + format: 'number' | 'currency'; + aggregation: DateAggregation[]; + change: ChangeStatistic; +} diff --git a/plugins/cost-insights/src/types/Product.ts b/plugins/cost-insights/src/types/Product.ts index d2087644cb..77df579a11 100644 --- a/plugins/cost-insights/src/types/Product.ts +++ b/plugins/cost-insights/src/types/Product.ts @@ -14,17 +14,7 @@ * limitations under the License. */ -import { Entity } from './Entity'; -import { ChangeStatistic } from './ChangeStatistic'; -import { Maybe } from './Maybe'; - export interface Product { kind: string; name: string; } - -export interface ProductCost { - entities?: Array; - aggregation: [number, number]; - change?: Maybe; -} diff --git a/plugins/cost-insights/src/types/index.ts b/plugins/cost-insights/src/types/index.ts index c3d9a87886..398110ec80 100644 --- a/plugins/cost-insights/src/types/index.ts +++ b/plugins/cost-insights/src/types/index.ts @@ -16,6 +16,7 @@ export * from './Alert'; export * from './ChangeStatistic'; +export * from './ChartData'; export * from './Cost'; export * from './DateAggregation'; export * from './Duration'; @@ -26,6 +27,7 @@ export * from './Filters'; export * from './Group'; export * from './Loading'; export * from './Maybe'; +export * from './MetricData'; export * from './Metric'; export * from './Product'; export * from './Project'; diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx index 07602be191..fbb148f38a 100644 --- a/plugins/cost-insights/src/utils/alerts.tsx +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -13,70 +13,51 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; -import { Alert, AlertType, assertNever } from '../types'; -import ProjectGrowthAlertCard from '../components/ProjectGrowthAlertCard'; -import UnlabeledDataflowAlertCard from '../components/UnlabeledDataflowAlertCard'; +import { Alert, UnlabeledDataflowData, ProjectGrowthData } from '../types'; +import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; +import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard'; -export function getAlertText(alert: Alert) { - switch (alert.id) { - case AlertType.ProjectGrowth: - return { - title: `Investigate cost growth in project ${alert.project}`, - subtitle: - 'Cost growth outpacing business growth is unsustainable long-term.', - } as AlertText; - case AlertType.UnlabeledDataflow: - return { - title: 'Add labels to workflows', - subtitle: - 'Labels show in billing data, enabling cost insights for each workflow.', - }; - default: - return assertNever(alert); +/** + * The alerts below are examples of Alert implementation; the CostInsightsApi permits returning + * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi + * fetches alert data from the backend, then creates Alert classes with the data. + */ + +export class UnlabeledDataflowAlert implements Alert { + data: UnlabeledDataflowData; + + constructor(data: UnlabeledDataflowData) { + this.data = data; + } + + title = 'Add labels to workflows'; + subtitle = + 'Labels show in billing data, enabling cost insights for each workflow.'; + url = '/cost-insights/labeling-jobs'; + + get element() { + return ; } } -export function getAlertUrl(alert: Alert) { - switch (alert.id) { - case AlertType.ProjectGrowth: - return '/cost-insights/investigating-growth' as AlertUrl; - case AlertType.UnlabeledDataflow: - return '/cost-insights/labeling-jobs' as AlertUrl; - default: - return assertNever(alert); +export class ProjectGrowthAlert implements Alert { + data: ProjectGrowthData; + + constructor(data: ProjectGrowthData) { + this.data = data; + } + + get title() { + return `Investigate cost growth in project ${this.data.project}`; + } + + subtitle = + 'Cost growth outpacing business growth is unsustainable long-term.'; + url = '/cost-insights/investigating-growth'; + + get element() { + return ; } } - -export function getAlertButtonText(alert: Alert) { - switch (alert.id) { - case AlertType.ProjectGrowth: - case AlertType.UnlabeledDataflow: - return 'View Instructions' as AlertButtonText; - default: - return assertNever(alert); - } -} - -export function getAlertNavigation(alert: Alert, number: number) { - return `${alert.id}-${number}`; -} - -export function renderAlert(alert: Alert) { - switch (alert.id) { - case AlertType.ProjectGrowth: - return ; - case AlertType.UnlabeledDataflow: - return ; - default: - return assertNever(alert); - } -} - -export type AlertUrl = string; -export type AlertButtonText = string; - -export interface AlertText { - title: string; - subtitle: string; -} diff --git a/plugins/cost-insights/src/utils/assert.ts b/plugins/cost-insights/src/utils/assert.ts new file mode 100644 index 0000000000..8f7caef0fa --- /dev/null +++ b/plugins/cost-insights/src/utils/assert.ts @@ -0,0 +1,44 @@ +/* + * 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 function notEmpty( + value: TValue | null | undefined, +): value is TValue { + return value !== null && value !== undefined; +} + +// Utility for exhaustiveness checking in switch statements +export function assertNever(x: never): never { + throw new Error(`Exhaustiveness check failed: ${x}`); +} + +export function assertAlways(argument: T | undefined): T { + if (argument === undefined) { + throw new TypeError( + 'Expected to always find a value but received undefined', + ); + } + return argument; +} + +// Utility for working with static lists; asserts a value will always be found or +// throws an error +export function findAlways( + collection: T[], + callback: (el: T) => boolean, +): T { + return assertAlways(collection.find(callback)); +} diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts new file mode 100644 index 0000000000..6a0a8e793b --- /dev/null +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { growthOf, getPreviousPeriodTotalCost } from './change'; +import { + GrowthType, + ChangeThreshold, + EngineerThreshold, + Duration, + Cost, +} from '../types'; +import { MockAggregatedDailyCosts, trendlineOf, changeOf } from './mockData'; + +const GrowthMap = { + [GrowthType.Negligible]: 'negligible growth', + [GrowthType.Savings]: 'cost savings', + [GrowthType.Excess]: 'cost excess', +}; + +describe.each` + ratio | amount | expected + ${0.0} | ${undefined} | ${GrowthType.Negligible} + ${0.0} | ${EngineerThreshold} | ${GrowthType.Negligible} + ${ChangeThreshold.lower} | ${0} | ${GrowthType.Negligible} + ${ChangeThreshold.lower + 0.01} | ${undefined} | ${GrowthType.Negligible} + ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold} | ${GrowthType.Negligible} + ${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Negligible} + ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible} + ${ChangeThreshold.upper - 0.01} | ${undefined} | ${GrowthType.Negligible} + ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible} + ${ChangeThreshold.lower} | ${undefined} | ${GrowthType.Savings} + ${ChangeThreshold.lower} | ${EngineerThreshold} | ${GrowthType.Savings} + ${ChangeThreshold.lower - 0.01} | ${undefined} | ${GrowthType.Savings} + ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${GrowthType.Savings} + ${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Savings} + ${ChangeThreshold.upper} | ${undefined} | ${GrowthType.Excess} + ${ChangeThreshold.upper} | ${EngineerThreshold} | ${GrowthType.Excess} + ${ChangeThreshold.upper + 0.01} | ${undefined} | ${GrowthType.Excess} + ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${GrowthType.Excess} + ${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Excess} +`( + 'growthOf', + ({ + ratio, + amount, + expected, + }: { + ratio: number; + amount: number; + expected: GrowthType; + }) => { + it(`should display ${GrowthMap[expected]}`, () => { + expect(growthOf(ratio, amount)).toBe(expected); + }); + }, +); + +describe('getPreviousPeriodTotalCost', () => { + it('Correctly returns the total cost for the previous period given daily costs', () => { + const mockGroupDailyCost: Cost = { + id: 'test-group', + aggregation: MockAggregatedDailyCosts, + change: changeOf(MockAggregatedDailyCosts), + trendline: trendlineOf(MockAggregatedDailyCosts), + }; + const exclusiveEndDate = '2020-09-30'; + expect( + getPreviousPeriodTotalCost( + mockGroupDailyCost, + Duration.P1M, + exclusiveEndDate, + ), + ).toEqual(100_000); + }); +}); diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts new file mode 100644 index 0000000000..1718462948 --- /dev/null +++ b/plugins/cost-insights/src/utils/change.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 { + Cost, + ChangeStatistic, + ChangeThreshold, + EngineerThreshold, + GrowthType, + MetricData, + Duration, + DEFAULT_DATE_FORMAT, +} from '../types'; +import dayjs, { OpUnitType } from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { inclusiveStartDateOf } from './duration'; + +dayjs.extend(duration); + +// Used for displaying status colors +export function growthOf(ratio: number, amount?: number) { + if (typeof amount === 'number') { + if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) { + return GrowthType.Excess; + } + if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) { + return GrowthType.Savings; + } + } else { + if (ratio >= ChangeThreshold.upper) return GrowthType.Excess; + if (ratio <= ChangeThreshold.lower) return GrowthType.Savings; + } + + return GrowthType.Negligible; +} + +// Used by for displaying engineer totals +export function getComparedChange( + dailyCost: Cost, + metricData: MetricData, + duration: Duration, + lastCompleteBillingDate: string, // YYYY-MM-DD, +): ChangeStatistic { + const ratio = dailyCost.change.ratio - metricData.change.ratio; + const previousPeriodTotal = getPreviousPeriodTotalCost( + dailyCost, + duration, + lastCompleteBillingDate, + ); + return { + ratio: ratio, + amount: previousPeriodTotal * ratio, + }; +} + +export function getPreviousPeriodTotalCost( + dailyCost: Cost, + duration: Duration, + inclusiveEndDate: string, +): number { + const dayjsDuration = dayjs.duration(duration); + const startDate = inclusiveStartDateOf( + duration, + dayjs(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT), + ); + // dayjs doesn't allow adding an ISO 8601 period to dates. + const [amount, type]: [number, OpUnitType] = dayjsDuration.days() + ? [dayjsDuration.days(), 'day'] + : [dayjsDuration.months(), 'month']; + const nextPeriodStart = dayjs(startDate).add(amount, type); + + // Add up costs that incurred before the start of the next period. + return dailyCost.aggregation.reduce((acc, costByDate) => { + return dayjs(costByDate.date).isBefore(nextPeriodStart) + ? acc + costByDate.amount + : acc; + }, 0); +} diff --git a/plugins/cost-insights/src/utils/charts.ts b/plugins/cost-insights/src/utils/charts.ts new file mode 100644 index 0000000000..60a8e85816 --- /dev/null +++ b/plugins/cost-insights/src/utils/charts.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateAggregation, Trendline, ChartData } from '../types'; + +export function trendFrom(trendline: Trendline, date: number): number { + return trendline.slope * (date / 1000) + trendline.intercept; +} + +export function groupByDate( + acc: Record, + entry: DateAggregation, +): Record { + return { ...acc, [entry.date]: entry.amount }; +} + +export function toMaxCost(acc: ChartData, entry: ChartData): ChartData { + return acc.dailyCost > entry.dailyCost ? acc : entry; +} + +export function toDataMax(metric: string, data: ChartData[]): number { + return ( + (data.reduce(toMaxCost).dailyCost / Math.abs(data[0].trend)) * + data[0][metric] + ); +} diff --git a/plugins/cost-insights/src/utils/config.ts b/plugins/cost-insights/src/utils/config.ts new file mode 100644 index 0000000000..4af7140d9c --- /dev/null +++ b/plugins/cost-insights/src/utils/config.ts @@ -0,0 +1,26 @@ +/* + * 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 { Metric } from '../types'; + +export function validateMetrics(metrics: Metric[]) { + const defaults = metrics.filter(metric => metric.default); + if (defaults.length > 1) { + throw new Error( + `Only one default metric can be set at a time. Found ${defaults.length}`, + ); + } +} diff --git a/plugins/cost-insights/src/utils/currency.ts b/plugins/cost-insights/src/utils/currency.ts new file mode 100644 index 0000000000..663a29a112 --- /dev/null +++ b/plugins/cost-insights/src/utils/currency.ts @@ -0,0 +1,63 @@ +/* + * 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 { Currency, CurrencyType, Duration } from '../types'; +import { assertNever } from '../utils/assert'; + +export const rateOf = (cost: number, duration: Duration) => { + switch (duration) { + case Duration.P1M: + case Duration.P30D: + return cost / 12; + case Duration.P90D: + case Duration.P3M: + return cost / 4; + default: + return assertNever(duration); + } +}; + +export const defaultCurrencies: Currency[] = [ + { + kind: null, + label: 'Engineers 🛠', + unit: 'engineer', + }, + { + kind: CurrencyType.USD, + label: 'US Dollars 💵', + unit: 'dollar', + prefix: '$', + rate: 1, + }, + { + kind: CurrencyType.CarbonOffsetTons, + label: 'Carbon Offset Tons ♻️⚖️s', + unit: 'carbon offset ton', + rate: 3.5, + }, + { + kind: CurrencyType.Beers, + label: 'Beers 🍺', + unit: 'beer', + rate: 4.5, + }, + { + kind: CurrencyType.IceCream, + label: 'Pints of Ice Cream 🍦', + unit: 'ice cream pint', + rate: 5.5, + }, +]; diff --git a/plugins/cost-insights/src/types/Duration.test.ts b/plugins/cost-insights/src/utils/duration.test.ts similarity index 75% rename from plugins/cost-insights/src/types/Duration.test.ts rename to plugins/cost-insights/src/utils/duration.test.ts index 5b9f7b4a9b..a5509eda07 100644 --- a/plugins/cost-insights/src/types/Duration.test.ts +++ b/plugins/cost-insights/src/utils/duration.test.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import { Duration, inclusiveEndDateOf, inclusiveStartDateOf } from './Duration'; +import { Duration } from '../types'; +import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; -Date.now = jest.fn(() => new Date(Date.parse('2020-06-05')).valueOf()); +const lastCompleteBillingDate = '2020-06-05'; describe.each` duration | startDate | endDate @@ -26,7 +27,9 @@ describe.each` ${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'} `('Calculates interval dates correctly', ({ duration, startDate, endDate }) => { it(`Calculates dates correctly for ${duration}`, () => { - expect(inclusiveStartDateOf(duration)).toBe(startDate); - expect(inclusiveEndDateOf(duration)).toBe(endDate); + expect(inclusiveStartDateOf(duration, lastCompleteBillingDate)).toBe( + startDate, + ); + expect(inclusiveEndDateOf(duration, lastCompleteBillingDate)).toBe(endDate); }); }); diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts new file mode 100644 index 0000000000..ae8c255b91 --- /dev/null +++ b/plugins/cost-insights/src/utils/duration.ts @@ -0,0 +1,94 @@ +/* + * 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 moment from 'moment'; +import { Duration, DEFAULT_DATE_FORMAT } from '../types'; +import { assertNever } from './assert'; + +/** + * Derive the start date of a given period, assuming two repeating intervals. + * + * @param duration see comment on Duration enum + * @param endDate from CostInsightsApi.getLastCompleteBillingDate + 1 day + */ +export function inclusiveStartDateOf( + duration: Duration, + exclusiveEndDate: string, +): string { + switch (duration) { + case Duration.P30D: + case Duration.P90D: + return moment(exclusiveEndDate) + .utc() + .subtract(moment.duration(duration).add(moment.duration(duration))) + .format(DEFAULT_DATE_FORMAT); + case Duration.P1M: + return moment(exclusiveEndDate) + .utc() + .startOf('month') + .subtract(moment.duration(duration).add(moment.duration(duration))) + .format(DEFAULT_DATE_FORMAT); + case Duration.P3M: + return moment(exclusiveEndDate) + .utc() + .startOf('quarter') + .subtract(moment.duration(duration).add(moment.duration(duration))) + .format(DEFAULT_DATE_FORMAT); + default: + return assertNever(duration); + } +} + +export function exclusiveEndDateOf( + duration: Duration, + inclusiveEndDate: string, +): string { + switch (duration) { + case Duration.P30D: + case Duration.P90D: + return moment(inclusiveEndDate) + .utc() + .add(1, 'day') + .format(DEFAULT_DATE_FORMAT); + case Duration.P1M: + return moment(inclusiveEndDate) + .utc() + .startOf('month') + .format(DEFAULT_DATE_FORMAT); + case Duration.P3M: + return moment(inclusiveEndDate) + .utc() + .startOf('quarter') + .format(DEFAULT_DATE_FORMAT); + default: + return assertNever(duration); + } +} + +export function inclusiveEndDateOf( + duration: Duration, + inclusiveEndDate: string, +): string { + return moment(exclusiveEndDateOf(duration, inclusiveEndDate)) + .utc() + .subtract(1, 'day') + .format(DEFAULT_DATE_FORMAT); +} + +// https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals +export function intervalsOf(duration: Duration, inclusiveEndDate: string) { + return `R2/${duration}/${exclusiveEndDateOf(duration, inclusiveEndDate)}`; +} diff --git a/plugins/cost-insights/src/utils/filters.ts b/plugins/cost-insights/src/utils/filters.ts new file mode 100644 index 0000000000..fbe1bf46cc --- /dev/null +++ b/plugins/cost-insights/src/utils/filters.ts @@ -0,0 +1,26 @@ +/* + * 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 { Duration, Group, PageFilters } from '../types'; + +export function getDefaultPageFilters(groups: Group[]): PageFilters { + return { + group: groups.length ? groups[0].id : null, + project: null, + duration: Duration.P90D, + metric: null, + }; +} diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts index a76e838b53..69e9086be1 100644 --- a/plugins/cost-insights/src/utils/formatters.test.ts +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import { lengthyCurrencyFormatter, quarterOf } from './formatters'; +import { + formatPeriod, + lengthyCurrencyFormatter, + quarterOf, +} from './formatters'; +import { Duration } from '../types'; Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf()); @@ -48,3 +53,19 @@ describe('date formatters', () => { ]); }); }); + +describe.each` + duration | date | isEndDate | output + ${Duration.P1M} | ${'2020-10-11'} | ${true} | ${'September 2020'} + ${Duration.P1M} | ${'2020-10-11'} | ${false} | ${'August 2020'} + ${Duration.P3M} | ${'2020-10-11'} | ${true} | ${'Q3 2020'} + ${Duration.P3M} | ${'2020-10-11'} | ${false} | ${'Q2 2020'} + ${Duration.P30D} | ${'2020-10-11'} | ${true} | ${'Last 30 Days'} + ${Duration.P30D} | ${'2020-10-11'} | ${false} | ${'First 30 Days'} + ${Duration.P90D} | ${'2020-10-11'} | ${true} | ${'Last 90 Days'} + ${Duration.P90D} | ${'2020-10-11'} | ${false} | ${'First 90 Days'} +`('formatPeriod', ({ duration, date, isEndDate, output }) => { + it(`Correctly formats ${duration} with date ${date}`, async () => { + expect(formatPeriod(duration, date, isEndDate)).toBe(output); + }); +}); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index f3790f315e..42505e7072 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -15,11 +15,8 @@ */ import moment from 'moment'; -import { - Duration, - inclusiveEndDateOf, - inclusiveStartDateOf, -} from '../types/Duration'; +import { Duration, DEFAULT_DATE_FORMAT } from '../types'; +import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration'; import { pluralOf } from '../utils/grammar'; export type Period = { @@ -27,6 +24,11 @@ export type Period = { periodEnd: string; }; +export const costFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + export const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', @@ -87,26 +89,39 @@ export function formatPercent(n: number): string { return `${(n * 100).toFixed(0)}%`; } -export const formatLastTwoLookaheadQuarters = () => { - const start = moment(inclusiveStartDateOf(Duration.P3M)).format('[Q]Q YYYY'); - const end = moment(inclusiveEndDateOf(Duration.P3M)).format('[Q]Q YYYY'); +export function formatLastTwoLookaheadQuarters(inclusiveEndDate: string) { + const exclusiveEndDate = moment(inclusiveEndDate) + .add(1, 'day') + .format(DEFAULT_DATE_FORMAT); + const start = moment( + inclusiveStartDateOf(Duration.P3M, exclusiveEndDate), + ).format('[Q]Q YYYY'); + const end = moment(inclusiveEndDateOf(Duration.P3M, inclusiveEndDate)).format( + '[Q]Q YYYY', + ); return `${start} vs ${end}`; -}; +} -export const formatLastTwoMonths = () => { - const start = moment(inclusiveStartDateOf(Duration.P1M)).utc().format('MMMM'); - const end = moment(inclusiveEndDateOf(Duration.P1M)).utc().format('MMMM'); +export function formatLastTwoMonths(inclusiveEndDate: string) { + const exclusiveEndDate = moment(inclusiveEndDate) + .add(1, 'day') + .format(DEFAULT_DATE_FORMAT); + const start = moment(inclusiveStartDateOf(Duration.P1M, exclusiveEndDate)) + .utc() + .format('MMMM'); + const end = moment(inclusiveEndDateOf(Duration.P1M, inclusiveEndDate)) + .utc() + .format('MMMM'); return `${start} vs ${end}`; -}; +} -export const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/; - -export const formatRelativeDuration = ( - date: string, +const formatRelativePeriod = ( duration: Duration, + date: string, + isEndDate: boolean, ): string => { - const periodStart = inclusiveStartDateOf(duration); - const periodEnd = inclusiveEndDateOf(duration); + const periodStart = isEndDate ? inclusiveStartDateOf(duration, date) : date; + const periodEnd = isEndDate ? date : inclusiveEndDateOf(duration, date); const days = moment.duration(duration).asDays(); if (![periodStart, periodEnd].includes(date)) { throw new Error(`Invalid relative date ${date} for duration ${duration}`); @@ -114,13 +129,25 @@ export const formatRelativeDuration = ( return date === periodStart ? `First ${days} Days` : `Last ${days} Days`; }; -export function formatDuration(date: string, duration: Duration) { +export function formatPeriod( + duration: Duration, + date: string, + isEndDate: boolean, +) { switch (duration) { case Duration.P1M: - return monthOf(date); + return monthOf( + isEndDate + ? inclusiveEndDateOf(duration, date) + : inclusiveStartDateOf(duration, date), + ); case Duration.P3M: - return quarterOf(date); + return quarterOf( + isEndDate + ? inclusiveEndDateOf(duration, date) + : inclusiveStartDateOf(duration, date), + ); default: - return formatRelativeDuration(date, duration); + return formatRelativePeriod(duration, date, isEndDate); } } diff --git a/plugins/cost-insights/src/utils/graphs.ts b/plugins/cost-insights/src/utils/graphs.ts new file mode 100644 index 0000000000..ac06e20587 --- /dev/null +++ b/plugins/cost-insights/src/utils/graphs.ts @@ -0,0 +1,79 @@ +/* + * 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 { TooltipPayload, TooltipProps } from 'recharts'; +import { AlertCost, DataKey, Entity, ResourceData } from '../types'; +import { + currencyFormatter, + dateFormatter, + lengthyCurrencyFormatter, +} from './formatters'; + +export function formatGraphValue(value: number, format?: string) { + if (format === 'number') { + return value.toLocaleString(); + } + + if (value < 1) { + return lengthyCurrencyFormatter.format(value); + } + + return currencyFormatter.format(value); +} + +export const overviewGraphTickFormatter = (millis: string | number) => + typeof millis === 'number' ? dateFormatter.format(millis) : millis; + +export const tooltipItemOf = (payload: TooltipPayload) => { + const value = + typeof payload.value === 'number' + ? currencyFormatter.format(payload.value) + : (payload.value as string); + const fill = payload.fill as string; + + switch (payload.dataKey) { + case DataKey.Current: + case DataKey.Previous: + return { + label: payload.name, + value: value, + fill: fill, + }; + default: + return null; + } +}; + +export const resourceOf = (entity: Entity | AlertCost): ResourceData => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], +}); + +export const titleOf = (label?: string | number) => { + return label ? String(label) : 'Unlabeled'; +}; + +export const isInvalid = ({ label, payload }: TooltipProps) => { + // null labels are empty strings, which are valid + return label === undefined || !payload || !payload.length; +}; + +export const isActiveLabel = ( + data?: Record<'activeLabel', string | undefined>, +) => { + return data?.activeLabel && data.activeLabel !== ''; +}; diff --git a/plugins/cost-insights/src/utils/history.test.ts b/plugins/cost-insights/src/utils/history.test.ts new file mode 100644 index 0000000000..e4310e13c6 --- /dev/null +++ b/plugins/cost-insights/src/utils/history.test.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 { validate, getInitialPageState } from './history'; + +describe('getInitialPageState', () => { + describe('groups', () => { + it('should set defaults if params or group is not provided', () => { + const initialState = getInitialPageState([]); + expect(initialState.group).toBe(null); + }); + + it('should set defaults if a group is fetched but no group is present on query params', () => { + const initialState = getInitialPageState([{ id: 'group' }]); + expect(initialState.group).toMatch(/group/); + }); + + it('group param should always override fetched group', () => { + const initialState = getInitialPageState( + [{ id: 'group' }, { id: 'second-group' }], + { group: 'other-group' }, + ); + expect(initialState.group).toMatch(/other-group/); + }); + + it('first group should be set as default group if user belongs to multiple groups', () => { + const initialState = getInitialPageState([ + { id: 'group' }, + { id: 'other-group' }, + ]); + expect(initialState.group).toMatch(/group/); + }); + }); + + describe('projects', () => { + it("should set defaults if project param doesn't exist", () => { + const initialState = getInitialPageState([], {}); + expect(initialState.project).toBeNull(); + }); + + it('should override defaults if project param exists', () => { + const initialState = getInitialPageState([], { project: 'some-project' }); + expect(initialState.project).toMatch(/some-project/); + }); + }); +}); + +describe.each` + params | expected + ${''} | ${{}} + ${'?foo=bar'} | ${{}} + ${'?project'} | ${{ project: null }} + ${'?group=some-group'} | ${{ group: 'some-group' }} + ${'?group=some-group&project'} | ${{ group: 'some-group', project: null }} + ${'?group=some-group&project=some-project'} | ${{ group: 'some-group', project: 'some-project' }} + ${'?group=some-group&project=some-project&foo=bar'} | ${{ group: 'some-group', project: 'some-project' }} +`('validate', ({ params, expected }) => { + it(`should validate ${params}`, async () => { + const pageFilters = await validate(params); + expect(pageFilters).toMatchObject(expected); + }); +}); + +describe('invalidate', () => { + it("should throw an error if param values don't match schema", async () => { + await expect(validate('?group')).rejects.toThrowError(); + }); +}); diff --git a/plugins/cost-insights/src/utils/history.ts b/plugins/cost-insights/src/utils/history.ts index 71e8e2c219..7d45cfdad2 100644 --- a/plugins/cost-insights/src/utils/history.ts +++ b/plugins/cost-insights/src/utils/history.ts @@ -15,9 +15,45 @@ */ import qs from 'qs'; -import { QueryParams } from '../types'; +import * as yup from 'yup'; +import { Duration, Group, PageFilters } from '../types'; +import { getDefaultPageFilters } from '../utils/filters'; +import { ConfigContextProps } from '../hooks/useConfig'; -export const stringify = (queryParams: Partial) => +const schema = yup + .object() + .shape({ + group: yup.string(), + project: yup.string().nullable(), + }) + .required(); + +export const stringify = (queryParams: Partial) => qs.stringify(queryParams, { strictNullHandling: true }); -export const parse = (queryString: string): Partial => + +export const parse = (queryString: string): Partial => qs.parse(queryString, { ignoreQueryPrefix: true, strictNullHandling: true }); + +export const validate = (queryString: string): Promise => { + return schema.validate(parse(queryString), { + stripUnknown: true, + strict: true, + }) as Promise; +}; + +export const getInitialPageState = ( + groups: Group[], + queryParams: Partial = {}, +) => { + return { + ...getDefaultPageFilters(groups), + ...(queryParams.project ? { project: queryParams.project } : {}), + ...(queryParams.group ? { group: queryParams.group } : {}), + }; +}; + +export const getInitialProductState = (config: ConfigContextProps) => + config.products.map(product => ({ + productType: product.kind, + duration: Duration.P30D, + })); diff --git a/plugins/cost-insights/src/utils/loading.ts b/plugins/cost-insights/src/utils/loading.ts new file mode 100644 index 0000000000..4652603926 --- /dev/null +++ b/plugins/cost-insights/src/utils/loading.ts @@ -0,0 +1,64 @@ +/* + * 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 { Loading } from '../types'; + +export enum DefaultLoadingAction { + UserGroups = 'user-groups', + LastCompleteBillingDate = 'billing-date', + CostInsightsInitial = 'cost-insights-initial', + CostInsightsPage = 'cost-insights-page', +} + +export const INITIAL_LOADING_ACTIONS = [ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, +]; + +export const getDefaultState = (loadingActions: string[]): Loading => { + return loadingActions.reduce( + (defaultState, action) => ({ ...defaultState, [action]: true }), + {}, + ); +}; + +export const getResetState = (loadingActions: string[]): Loading => { + return loadingActions.reduce( + (defaultState, action) => ({ ...defaultState, [action]: false }), + {}, + ); +}; + +export const getResetStateWithoutInitial = ( + loadingActions: string[], +): Loading => { + return loadingActions.reduce((defaultState, action) => { + const loadingActionState = (INITIAL_LOADING_ACTIONS as string[]).includes( + action, + ) + ? false + : true; + return { ...defaultState, [action]: loadingActionState }; + }, {}); +}; + +export function getLoadingActions(products: string[]): string[] { + return ([ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, + DefaultLoadingAction.CostInsightsPage, + ] as string[]).concat(products); +} diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index 87dbcf8158..708ce733dd 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -14,21 +14,26 @@ * limitations under the License. */ -import { - AlertType, - Entity, - ProjectGrowthAlert, - Product, - UnlabeledDataflowAlert, - UnlabeledDataflowAlertProject, - getDefaultState, - DefaultLoadingAction, - Duration, - ProductCost, - findAlways, -} from '../types'; +import regression, { DataPoint } from 'regression'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/core'; +import { + ChangeStatistic, + Duration, + Entity, + Product, + ProductFilters, + ProjectGrowthData, + Trendline, + UnlabeledDataflowAlertProject, + UnlabeledDataflowData, + DateAggregation, +} from '../types'; +import { + DefaultLoadingAction, + getDefaultState as getDefaultLoadingState, +} from '../utils/loading'; +import { findAlways } from '../utils/assert'; type mockAlertRenderer = (alert: T) => T; type mockEntityRenderer = (entity: T) => T; @@ -39,6 +44,11 @@ export const createMockEntity = ( const defaultEntity: Entity = { id: 'test-entity', aggregation: [100, 200], + entities: [], + change: { + ratio: 0, + amount: 0, + }, }; if (typeof callback === 'function') { @@ -60,27 +70,13 @@ export const createMockProduct = ( return { ...defaultProduct }; }; -export const createMockProductCost = ( - callback?: mockEntityRenderer, -): ProductCost => { - const defaultProduct: ProductCost = { - entities: [], - aggregation: [0, 0], - }; - if (typeof callback === 'function') { - return callback({ ...defaultProduct }); - } - return { ...defaultProduct }; -}; - -export const createMockProjectGrowthAlert = ( - callback?: mockAlertRenderer, -): ProjectGrowthAlert => { - const defaultAlert: ProjectGrowthAlert = { - id: AlertType.ProjectGrowth, +export const createMockProjectGrowthData = ( + callback?: mockAlertRenderer, +): ProjectGrowthData => { + const data: ProjectGrowthData = { project: 'test-project-growth-alert', - periodStart: '2019-10-01', - periodEnd: '2020-03-31', + periodStart: '2019-Q4', + periodEnd: '2020-Q1', aggregation: [670532.1, 970502.8], change: { ratio: 0.5, @@ -90,17 +86,16 @@ export const createMockProjectGrowthAlert = ( }; if (typeof callback === 'function') { - return callback({ ...defaultAlert }); + return callback({ ...data }); } - return { ...defaultAlert }; + return { ...data }; }; -export const createMockUnlabeledDataflowAlert = ( - callback?: mockAlertRenderer, -): UnlabeledDataflowAlert => { - const defaultAlert: UnlabeledDataflowAlert = { - id: AlertType.UnlabeledDataflow, +export const createMockUnlabeledDataflowData = ( + callback?: mockAlertRenderer, +): UnlabeledDataflowData => { + const data: UnlabeledDataflowData = { periodStart: '2020-05-01', periodEnd: '2020-06-1', projects: [], @@ -109,10 +104,10 @@ export const createMockUnlabeledDataflowAlert = ( }; if (typeof callback === 'function') { - return callback({ ...defaultAlert }); + return callback({ ...data }); } - return { ...defaultAlert }; + return { ...data }; }; export const createMockUnlabeledDataflowAlertProject = ( @@ -138,7 +133,7 @@ export const MockProductTypes: Record = { 'cloud-pub-sub': 'Cloud Pub/Sub', }; -export const MockProductFilters = Object.keys( +export const MockProductFilters: ProductFilters = Object.keys( MockProductTypes, ).map(productType => ({ duration: Duration.P1M, productType })); @@ -156,7 +151,9 @@ export const MockLoadingActions = ([ DefaultLoadingAction.CostInsightsPage, ] as string[]).concat(MockProducts.map(product => product.kind)); -export const mockDefaultState = getDefaultState(MockLoadingActions); +export const mockDefaultLoadingState = getDefaultLoadingState( + MockLoadingActions, +); export const MockComputeEngine = findAlways( MockProducts, @@ -192,3 +189,276 @@ export const MockCostInsightsConfig: Partial = { getConfig: () => MockProductsConfig as Config, getOptionalConfig: () => MockMetricsConfig as Config, }; + +export function trendlineOf(aggregation: DateAggregation[]): Trendline { + const data: ReadonlyArray = aggregation.map(a => [ + Date.parse(a.date) / 1000, + a.amount, + ]); + const result = regression.linear(data, { precision: 5 }); + return { + slope: result.equation[0], + intercept: result.equation[1], + }; +} + +export function changeOf(aggregation: DateAggregation[]): ChangeStatistic { + const half = Math.ceil(aggregation.length / 2); + const before = aggregation + .slice(0, half) + .reduce((sum, a) => sum + a.amount, 0); + const after = aggregation + .slice(half, aggregation.length) + .reduce((sum, a) => sum + a.amount, 0); + return { + ratio: (after - before) / before, + amount: after - before, + }; +} + +export const MockAggregatedDailyCosts: DateAggregation[] = [ + { + date: '2020-08-07', + amount: 3500, + }, + { + date: '2020-08-06', + amount: 2500, + }, + { + date: '2020-08-05', + amount: 1400, + }, + { + date: '2020-08-04', + amount: 3800, + }, + { + date: '2020-08-09', + amount: 1900, + }, + { + date: '2020-08-08', + amount: 2400, + }, + { + date: '2020-08-03', + amount: 4000, + }, + { + date: '2020-08-02', + amount: 3700, + }, + { + date: '2020-08-01', + amount: 2500, + }, + { + date: '2020-08-18', + amount: 4300, + }, + { + date: '2020-08-17', + amount: 1500, + }, + { + date: '2020-08-16', + amount: 3600, + }, + { + date: '2020-08-15', + amount: 2200, + }, + { + date: '2020-08-19', + amount: 3900, + }, + { + date: '2020-08-10', + amount: 4100, + }, + { + date: '2020-08-14', + amount: 3600, + }, + { + date: '2020-08-13', + amount: 2900, + }, + { + date: '2020-08-12', + amount: 2700, + }, + { + date: '2020-08-11', + amount: 5100, + }, + { + date: '2020-09-19', + amount: 1200, + }, + { + date: '2020-09-18', + amount: 6500, + }, + { + date: '2020-09-17', + amount: 2500, + }, + { + date: '2020-09-16', + amount: 1400, + }, + { + date: '2020-09-11', + amount: 2300, + }, + { + date: '2020-09-10', + amount: 1900, + }, + { + date: '2020-09-15', + amount: 3100, + }, + { + date: '2020-09-14', + amount: 4500, + }, + { + date: '2020-09-13', + amount: 3300, + }, + { + date: '2020-09-12', + amount: 2800, + }, + { + date: '2020-09-29', + amount: 2600, + }, + { + date: '2020-09-28', + amount: 4100, + }, + { + date: '2020-09-27', + amount: 3800, + }, + { + date: '2020-09-22', + amount: 3700, + }, + { + date: '2020-09-21', + amount: 2700, + }, + { + date: '2020-09-20', + amount: 2200, + }, + { + date: '2020-09-26', + amount: 3300, + }, + { + date: '2020-09-25', + amount: 4000, + }, + { + date: '2020-09-24', + amount: 3800, + }, + { + date: '2020-09-23', + amount: 4100, + }, + { + date: '2020-08-29', + amount: 4400, + }, + { + date: '2020-08-28', + amount: 5000, + }, + { + date: '2020-08-27', + amount: 4900, + }, + { + date: '2020-08-26', + amount: 4100, + }, + { + date: '2020-08-21', + amount: 3700, + }, + { + date: '2020-08-20', + amount: 2200, + }, + { + date: '2020-08-25', + amount: 1700, + }, + { + date: '2020-08-24', + amount: 2100, + }, + { + date: '2020-08-23', + amount: 3100, + }, + { + date: '2020-08-22', + amount: 1500, + }, + { + date: '2020-09-08', + amount: 2900, + }, + { + date: '2020-09-07', + amount: 4100, + }, + { + date: '2020-09-06', + amount: 3600, + }, + { + date: '2020-09-05', + amount: 3300, + }, + { + date: '2020-09-09', + amount: 2800, + }, + { + date: '2020-08-31', + amount: 3400, + }, + { + date: '2020-08-30', + amount: 4300, + }, + { + date: '2020-09-04', + amount: 6100, + }, + { + date: '2020-09-03', + amount: 2500, + }, + { + date: '2020-09-02', + amount: 4900, + }, + { + date: '2020-09-01', + amount: 6100, + }, + { + date: '2020-09-30', + amount: 5500, + }, +]; diff --git a/plugins/cost-insights/src/utils/sort.ts b/plugins/cost-insights/src/utils/sort.ts index 944f5fc806..f4fc1987d1 100644 --- a/plugins/cost-insights/src/utils/sort.ts +++ b/plugins/cost-insights/src/utils/sort.ts @@ -19,5 +19,6 @@ export const aggregationSort = ( a: DateAggregation, b: DateAggregation, ): number => a.date.localeCompare(b.date); + export const resourceSort = (a: ResourceData, b: ResourceData) => b.previous + b.current - (a.previous + a.current); diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts index e6b38ee719..1e8fd50899 100644 --- a/plugins/cost-insights/src/utils/styles.ts +++ b/plugins/cost-insights/src/utils/styles.ts @@ -15,6 +15,7 @@ */ import { createStyles, + emphasize, darken, getLuminance, lighten, @@ -116,6 +117,18 @@ export const useBarChartStyles = (theme: CostInsightsTheme) => ({ }, }); +export const useBarChartLayoutStyles = makeStyles(theme => + createStyles({ + wrapper: { + display: 'flex', + flexDirection: 'column', + }, + legend: { + paddingBottom: theme.spacing(2), + }, + }), +); + export const useBarChartStepperButtonStyles = makeStyles( (theme: CostInsightsTheme) => createStyles({ @@ -146,12 +159,13 @@ export const useBarChartStepperButtonStyles = makeStyles( }), ); -export const useBarChartLabelStyles = makeStyles(() => +export const useBarChartLabelStyles = makeStyles(theme => createStyles({ foreignObject: { textAlign: 'center', }, label: { + fontWeight: theme.typography.fontWeightBold, display: 'block', textDecoration: 'none', overflow: 'hidden', @@ -162,6 +176,11 @@ export const useBarChartLabelStyles = makeStyles(() => marginLeft: 2, fontSize: '1.25em', }, + button: { + textTransform: 'none', + fontWeight: theme.typography.fontWeightBold, + fontSize: theme.typography.fontSize, + }, }), ); @@ -244,6 +263,12 @@ export const useCostGrowthStyles = makeStyles( savings: { color: theme.palette.status.ok, }, + indicator: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + }, }), ); @@ -356,16 +381,37 @@ export const useTooltipStyles = makeStyles( (theme: CostInsightsTheme) => createStyles({ tooltip: { - padding: theme.spacing(1.5), backgroundColor: theme.palette.tooltip.background, borderRadius: theme.shape.borderRadius, boxShadow: theme.shadows[1], color: theme.palette.tooltip.color, fontSize: theme.typography.fontSize, + maxWidth: 300, + }, + actions: { + padding: theme.spacing(2), + }, + header: { + padding: theme.spacing(2), + }, + content: { + padding: theme.spacing(2), }, lensIcon: { fontSize: `.75rem`, }, + divider: { + backgroundColor: emphasize(theme.palette.divider, 1), + }, + truncate: { + maxWidth: 200, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + subtitle: { + fontStyle: 'italic', + }, }), ); @@ -385,6 +431,7 @@ export const useSelectStyles = makeStyles( select: { minWidth: 200, textAlign: 'start', + backgroundColor: theme.palette.background.paper, }, menuItem: { minWidth: 200, @@ -445,6 +492,21 @@ export const useProductInsightsCardStyles = makeStyles( }), ); +export const useProductInsightsChartStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + indicator: { + fontWeight: theme.typography.fontWeightBold, + fontSize: '1.25rem', + }, + actions: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + }), +); + export const useBackdropStyles = makeStyles( (theme: BackstageTheme) => createStyles({ @@ -453,3 +515,51 @@ export const useBackdropStyles = makeStyles( }, }), ); + +export const useSubtleTypographyStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + color: theme.palette.textSubtle, + }, + }), +); + +export const useEntityDialogStyles = makeStyles(theme => + createStyles({ + dialogContent: { + padding: 0, + }, + dialogTitle: { + padding: 0, + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + zIndex: 100, + }, + row: { + fontSize: theme.typography.fontSize * 1.25, + }, + rowTotal: { + fontWeight: theme.typography.fontWeightBold, + }, + colFirst: { + paddingLeft: theme.spacing(2), + }, + colLast: { + paddingRight: theme.spacing(2), + }, + column: { + fontWeight: theme.typography.fontWeightBold, + }, + growth: { + display: 'flex', + flexDirection: 'row', + alignContent: 'center', + justifyContent: 'flex-end', + }, + }), +); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 807232555b..852330acc3 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -13,74 +13,221 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { Dispatch, ReactNode, SetStateAction } from 'react'; + +import React, { PropsWithChildren } from 'react'; +import { costInsightsApiRef, CostInsightsApi } from '../api'; import { - getDefaultPageFilters, - PageFilters, - ProductFilters, - Group, -} from '../types'; -import { FilterContext } from '../hooks/useFilters'; + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, +} from '@backstage/core'; +import { LoadingContext, LoadingContextProps } from '../hooks/useLoading'; +import { GroupsContext, GroupsContextProps } from '../hooks/useGroups'; +import { FilterContext, FilterContextProps } from '../hooks/useFilters'; import { ConfigContext, ConfigContextProps } from '../hooks/useConfig'; import { CurrencyContext, CurrencyContextProps } from '../hooks/useCurrency'; -import { ScrollContext, ScrollProviderProps } from '../hooks/useScroll'; -import { MockProductFilters } from './mockData'; +import { + BillingDateContext, + BillingDateContextProps, +} from '../hooks/useLastCompleteBillingDate'; +import { ScrollContext, ScrollContextProps } from '../hooks/useScroll'; +import { Group, Duration } from '../types'; + +/* + Mock Providers and types are exposed publicly to allow users to test custom implementations + such as alerts, which may require context. + + Utility functions such as getDefaultPageFilters, etc. are intentionally + omitted as we do not want to expose explictly or implicitly internal implementations + that may be subject to change. + + Each Mock Provider provides minimal defaults which can be overridden, allowing users to define + context props only when necessary. +*/ + +type PartialPropsWithChildren = PropsWithChildren>; export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }]; -type MockFilterProviderProps = { - setPageFilters: Dispatch>; - setProductFilters: Dispatch>; - children: ReactNode; -}; +export type MockFilterProviderProps = PartialPropsWithChildren< + FilterContextProps +>; export const MockFilterProvider = ({ - setPageFilters, - setProductFilters, children, + ...context }: MockFilterProviderProps) => { - const pageFilters = getDefaultPageFilters(MockGroups); + const defaultContext: FilterContextProps = { + pageFilters: { + group: 'tech', + project: null, + duration: Duration.P90D, + metric: null, + }, + productFilters: [], + setPageFilters: jest.fn(), + setProductFilters: jest.fn(), + }; return ( - + {children} ); }; -export const MockConfigProvider = ({ - metrics, - products, - icons, - engineerCost, - currencies, +export type MockLoadingProviderProps = PartialPropsWithChildren< + LoadingContextProps +>; + +export const MockLoadingProvider = ({ children, -}: ConfigContextProps & { children: React.ReactNode }) => ( - - {children} - -); + ...context +}: MockLoadingProviderProps) => { + const defaultContext: LoadingContextProps = { + state: {}, + actions: [], + dispatch: jest.fn(), + }; + return ( + + {children} + + ); +}; + +export type MockConfigProviderProps = PartialPropsWithChildren< + ConfigContextProps +>; + +export const MockConfigProvider = ({ + children, + ...context +}: MockConfigProviderProps) => { + const defaultContext: ConfigContextProps = { + metrics: [], + products: [], + icons: [], + engineerCost: 0, + currencies: [], + }; + return ( + + {children} + + ); +}; + +export type MockCurrencyProviderProps = PartialPropsWithChildren< + CurrencyContextProps +>; export const MockCurrencyProvider = ({ - currency, - setCurrency, children, -}: CurrencyContextProps & { children: React.ReactNode }) => ( - - {children} - -); + ...context +}: MockCurrencyProviderProps) => { + const defaultContext: CurrencyContextProps = { + currency: { + kind: null, + label: 'Engineers 🛠', + unit: 'engineer', + }, + setCurrency: jest.fn(), + }; + return ( + + {children} + + ); +}; -export const MockScrollProvider = ({ children }: ScrollProviderProps) => ( - - {children} - -); +export type MockBillingDateProviderProps = PartialPropsWithChildren< + BillingDateContextProps +>; + +export const MockBillingDateProvider = ({ + children, + ...context +}: MockBillingDateProviderProps) => { + const defaultContext: BillingDateContextProps = { + lastCompleteBillingDate: '2020-10-01', + }; + return ( + + {children} + + ); +}; + +export type MockScrollProviderProps = PropsWithChildren<{}>; + +export const MockScrollProvider = ({ children }: MockScrollProviderProps) => { + const defaultContext: ScrollContextProps = { + scrollTo: null, + setScrollTo: jest.fn(), + }; + return ( + + {children} + + ); +}; + +export type MockGroupsProviderProps = PartialPropsWithChildren< + GroupsContextProps +>; + +export const MockGroupsProvider = ({ + children, + ...context +}: MockGroupsProviderProps) => { + const defaultContext: GroupsContextProps = { + groups: [], + }; + return ( + + {children} + + ); +}; + +export type MockCostInsightsApiProviderProps = PartialPropsWithChildren<{ + identityApi: Partial; + costInsightsApi: Partial; +}>; + +export const MockCostInsightsApiProvider = ({ + children, + ...context +}: MockCostInsightsApiProviderProps) => { + const defaultIdentityApi: IdentityApi = { + getProfile: jest.fn(), + getIdToken: jest.fn(), + getUserId: jest.fn(), + signOut: jest.fn(), + }; + + const defaultCostInsightsApi: CostInsightsApi = { + getAlerts: jest.fn(), + getDailyMetricData: jest.fn(), + getGroupDailyCost: jest.fn(), + getGroupProjects: jest.fn(), + getLastCompleteBillingDate: jest.fn(), + getProductInsights: jest.fn(), + getProjectDailyCost: jest.fn(), + getUserGroups: jest.fn(), + }; + + // TODO: defaultConfigApiRef: ConfigApiRef + + const defaultContext = ApiRegistry.from([ + [identityApiRef, { ...defaultIdentityApi, ...context.identityApi }], + [ + costInsightsApiRef, + { ...defaultCostInsightsApi, ...context.costInsightsApi }, + ], + // [configApiRef, { ...defaultConfigApiRef, ...context.configApiRef }] + ]); + + return {children}; +}; diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md new file mode 100644 index 0000000000..e4072fd0f2 --- /dev/null +++ b/plugins/explore/CHANGELOG.md @@ -0,0 +1,63 @@ +# @backstage/plugin-explore + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI +- cab473771: This PR adds Spotify's Cost Insights Tool. Cost Insights explains costs from cloud services in an understandable way, using software terms familiar to your engineers. This tool helps you and your team make trade-offs between cost optimization efforts and your other priorities. + + Cost Insights features: + + Daily cost graph by team or billing account + Cost comparison against configurable business metrics + Insights panels for configurable cloud products your company uses + Cost alerts and recommendations + Selectable time periods for month over month, or quarter over quarter cost comparison + Conversion of cost growth into average engineer cost (configurable) to help optimization trade-off decisions + + ![plugin-cost-insights](https://user-images.githubusercontent.com/3030003/94430416-e166d380-0161-11eb-891c-9ce10187683e.gif) + + This PR adds the Cost Insights frontend React plugin with a defined CostInsightsApi. We include an example client with static data in the expected format. This API should talk with a cloud billing backend that aggregates billing data from your cloud provider. + + Fixes #688 💵 + +### Patch Changes + +- Updated dependencies [819a70229] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/core@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 5bd78ab088..153184bdb1 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.1.1-alpha.24", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/core": "^0.3.1", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,17 +33,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", - "@backstage/test-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/components/ExplorePluginPage.tsx index 83402d8062..f3e75e0011 100644 --- a/plugins/explore/src/components/ExplorePluginPage.tsx +++ b/plugins/explore/src/components/ExplorePluginPage.tsx @@ -21,7 +21,6 @@ import { ContentHeader, Header, Page, - pageTheme, SupportButton, } from '@backstage/core'; import ExploreCard, { CardData } from './ExploreCard'; @@ -116,8 +115,9 @@ const toolsCards = [ export const ExplorePluginPage = () => { const classes = useStyles(); + return ( - +
); -export const NewProjectPage = () => { - return ( - -
- {labels} -
- - - - This plugin allows you to view and interact with your gcp projects. - - - - -
- ); -}; +export const NewProjectPage = () => ( + +
+ {labels} +
+ + + + This plugin allows you to view and interact with your gcp projects. + + + + +
+); diff --git a/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx b/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx index 3dfb4e886b..0c6350b849 100644 --- a/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx +++ b/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx @@ -13,14 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { Content, ContentHeader, Header, HeaderLabel, Page, - pageTheme, SupportButton, useApi, WarningPanel, @@ -147,18 +145,16 @@ const labels = ( ); -export const ProjectDetailsPage = () => { - return ( - -
- {labels} -
- - - Support Button - - - -
- ); -}; +export const ProjectDetailsPage = () => ( + +
+ {labels} +
+ + + Support Button + + + +
+); diff --git a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx index 542d03d5fd..b911040535 100644 --- a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx +++ b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx @@ -15,7 +15,6 @@ */ // NEEDS WORK - import { Content, ContentHeader, @@ -23,7 +22,6 @@ import { HeaderLabel, Link, Page, - pageTheme, SupportButton, useApi, WarningPanel, @@ -134,21 +132,19 @@ const PageContents = () => { ); }; -export const ProjectListPage = () => { - return ( - -
- {labels} -
- - - - All your software catalog entities - - - -
- ); -}; +export const ProjectListPage = () => ( + +
+ {labels} +
+ + + + All your software catalog entities + + + +
+); diff --git a/plugins/gcp-projects/src/setupTests.ts b/plugins/gcp-projects/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/gcp-projects/src/setupTests.ts +++ b/plugins/gcp-projects/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md new file mode 100644 index 0000000000..b679d6844b --- /dev/null +++ b/plugins/github-actions/CHANGELOG.md @@ -0,0 +1,85 @@ +# @backstage/plugin-github-actions + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [c5bab94ab] +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core-api@0.2.1 + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI +- d67c529ab: Adds a widget to show recent git workflow runs to the github actions plugin. The default setting is the last 5 runs across all branches but both branch and the number of runs are configurable. +- 6d97d2d6f: The InfoCard variant `'height100'` is deprecated. Use variant `'gridItem'` instead. + + When the InfoCard is displayed as a grid item within a grid, you may want items to have the same height for all items. + Set to the `'gridItem'` variant to display the InfoCard with full height suitable for Grid: + `...` + + Changed the InfoCards in '@backstage/plugin-github-actions', '@backstage/plugin-jenkins', '@backstage/plugin-lighthouse' + to pass an optional variant to the corresponding card of the plugin. + + As a result the overview content of the EntityPage shows cards with full height suitable for Grid. + +### Patch Changes + +- Updated dependencies [28edd7d29] +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [cbbd271c4] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [368fd8243] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [f0aa01bcc] +- Updated dependencies [0aecfded0] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [8b9c8196f] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [60d40892c] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [2ebcfac8d] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [0b956f21b] +- Updated dependencies [26e69ab1a] +- Updated dependencies [97c2cb19b] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [cbab5bbf8] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/plugin-catalog@0.2.0 + - @backstage/core-api@0.2.0 + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index 07a69cdfad..1a46022c58 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -26,8 +26,7 @@ TBD name: backstage description: backstage.io annotations: - github.com/project-slug: 'spotify/backstage' - + github.com/project-slug: 'backstage/backstage' spec: type: website lifecycle: production diff --git a/plugins/github-actions/examples/sample.yaml b/plugins/github-actions/examples/sample.yaml index 7d9de50463..beda3c58ed 100644 --- a/plugins/github-actions/examples/sample.yaml +++ b/plugins/github-actions/examples/sample.yaml @@ -4,8 +4,8 @@ metadata: name: backstage description: backstage.io annotations: - github.com/project-slug: 'spotify/backstage' - backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git + github.com/project-slug: 'backstage/backstage' + backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git spec: type: website lifecycle: production diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index a43e351d47..49ecfc5423 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.1.1-alpha.24", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/core-api": "^0.1.1-alpha.24", - "@backstage/plugin-catalog": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/core": "^0.3.1", + "@backstage/core-api": "^0.2.1", + "@backstage/plugin-catalog": "^0.2.2", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -40,16 +40,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/github-actions/src/api/types.ts b/plugins/github-actions/src/api/types.ts index ccc9198e58..00d622cccf 100644 --- a/plugins/github-actions/src/api/types.ts +++ b/plugins/github-actions/src/api/types.ts @@ -17,7 +17,7 @@ export type Step = { name: string; status: string; - conclusion: string; + conclusion?: string; number: number; // starts from 1 started_at: string; completed_at: string; diff --git a/plugins/github-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index 7e4d5f5fb6..1f1e2997df 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -81,10 +81,9 @@ const WidgetContent = ({ export const LatestWorkflowRunCard = ({ entity, branch = 'master', -}: { - entity: Entity; - branch: string; -}) => { + // Display the card full height suitable for + variant, +}: Props) => { const errorApi = useApi(errorApiRef); const [owner, repo] = ( entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/' @@ -102,7 +101,7 @@ export const LatestWorkflowRunCard = ({ }, [error, errorApi]); return ( - + ( - + variant, +}: Props) => ( + ); diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx index e5bba7fad9..46f60981a0 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -18,9 +18,9 @@ import { errorApiRef, useApi } from '@backstage/core-api'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; import { useWorkflowRuns } from '../useWorkflowRuns'; import React, { useEffect } from 'react'; -import { EmptyState, Table } from '@backstage/core'; +import { EmptyState, InfoCard, Table } from '@backstage/core'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; -import { Button, Card, Link, TableContainer } from '@material-ui/core'; +import { Button, Link } from '@material-ui/core'; import { generatePath, Link as RouterLink } from 'react-router-dom'; const firstLine = (message: string): string => message.split('\n')[0]; @@ -30,6 +30,7 @@ export type Props = { branch?: string; dense?: boolean; limit?: number; + variant?: string; }; export const RecentWorkflowRunsCard = ({ @@ -37,6 +38,7 @@ export const RecentWorkflowRunsCard = ({ branch, dense = false, limit = 5, + variant, }: Props) => { const errorApi = useApi(errorApiRef); const [owner, repo] = ( @@ -70,15 +72,19 @@ export const RecentWorkflowRunsCard = ({ } /> ) : ( - +
- + ); }; diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx index 7714ad6cb6..a2753589eb 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx +++ b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx @@ -77,8 +77,9 @@ const JobsList = ({ jobs, entity }: { jobs?: Jobs; entity: Entity }) => { {jobs && jobs.total_count > 0 && - jobs.jobs.map((job: Job) => ( + jobs.jobs.map(job => ( { @@ -142,8 +143,8 @@ const JobListItem = ({
- {job.steps.map((step: Step) => ( - + {job.steps.map(step => ( + ))}
diff --git a/plugins/github-actions/src/setupTests.ts b/plugins/github-actions/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/github-actions/src/setupTests.ts +++ b/plugins/github-actions/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/gitops-profiles/CHANGELOG.md b/plugins/gitops-profiles/CHANGELOG.md new file mode 100644 index 0000000000..7aa4253a2e --- /dev/null +++ b/plugins/gitops-profiles/CHANGELOG.md @@ -0,0 +1,47 @@ +# @backstage/plugin-gitops-profiles + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI + +### Patch Changes + +- Updated dependencies [819a70229] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/core@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 7c63ea350c..ad80d26e61 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-gitops-profiles", - "version": "0.1.1-alpha.24", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/core": "^0.3.1", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -32,16 +32,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx index baab018415..70bf4c8c3f 100644 --- a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx +++ b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx @@ -21,7 +21,6 @@ import { Header, SupportButton, Page, - pageTheme, Progress, HeaderLabel, useApi, @@ -91,7 +90,7 @@ const ClusterList: FC<{}> = () => { } return ( - +
diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx index b2d62e8650..9b6e5bdb08 100644 --- a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -13,13 +13,11 @@ * 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, @@ -85,7 +83,7 @@ const ClusterPage: FC<{}> = () => { }, [pollingLog, api, params, githubAuth, githubAccessToken, githubUsername]); return ( - +
diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx index 06074ff84d..f01ac70e64 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx @@ -16,7 +16,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; import ProfileCatalog from './ProfileCatalog'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; @@ -45,7 +44,6 @@ describe('ProfileCatalog', () => { }), ], ]); - mockFetch.mockResponse(() => new Promise(() => {})); const rendered = render( diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx index a5125fb006..a70cf97342 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.tsx @@ -18,7 +18,6 @@ import React, { FC, useEffect, useState } from 'react'; import { Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -259,7 +258,7 @@ const ProfileCatalog: FC<{}> = () => { ]; return ( - +
({ describe('GraphiQLPage', () => { it('should show progress', async () => { + jest.useFakeTimers(); const loadingApi: GraphQLBrowseApi = { async getEndpoints() { await new Promise(() => {}); @@ -43,9 +45,12 @@ describe('GraphiQLPage', () => { , , ); - + act(() => { + jest.advanceTimersByTime(250); + }); rendered.getByText('GraphiQL'); rendered.getByTestId('progress'); + jest.useRealTimers(); }); it('should show error', async () => { diff --git a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx index 7eb91b59d8..224def1d78 100644 --- a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx +++ b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import React from 'react'; import { Content, @@ -21,7 +20,6 @@ import { HeaderLabel, Page, Progress, - pageTheme, useApi, } from '@backstage/core'; import { useAsync } from 'react-use'; @@ -60,7 +58,7 @@ export const GraphiQLPage = () => { } return ( - +
diff --git a/plugins/graphiql/src/setupTests.ts b/plugins/graphiql/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/graphiql/src/setupTests.ts +++ b/plugins/graphiql/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/graphql/CHANGELOG.md b/plugins/graphql/CHANGELOG.md new file mode 100644 index 0000000000..477bd1bb8b --- /dev/null +++ b/plugins/graphql/CHANGELOG.md @@ -0,0 +1,29 @@ +# @backstage/plugin-graphql-backend + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + - @backstage/plugin-catalog-graphql@0.2.1 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [28edd7d29] +- Updated dependencies [5249594c5] +- Updated dependencies [56e4eb589] +- Updated dependencies [e37c0a005] +- Updated dependencies [f00ca3cb8] +- Updated dependencies [6579769df] +- Updated dependencies [8c2b76e45] +- Updated dependencies [440a17b39] +- Updated dependencies [8afce088a] +- Updated dependencies [7bbeb049f] + - @backstage/plugin-catalog-graphql@0.2.0 + - @backstage/backend-common@0.2.0 diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index edd3f2782e..53af8d9154 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-graphql-backend", - "version": "0.1.1-alpha.24", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,9 +19,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", - "@backstage/plugin-catalog-graphql": "^0.1.1-alpha.24", + "@backstage/backend-common": "^0.3.0", + "@backstage/config": "^0.1.1", + "@backstage/plugin-catalog-graphql": "^0.2.1", "@graphql-modules/core": "^0.7.17", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", @@ -30,13 +30,12 @@ "express-promise-router": "^3.0.3", "graphql": "^15.3.0", "helmet": "^4.0.0", - "node-fetch": "^2.6.0", "reflect-metadata": "^0.1.13", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md new file mode 100644 index 0000000000..44febbc63d --- /dev/null +++ b/plugins/jenkins/CHANGELOG.md @@ -0,0 +1,83 @@ +# @backstage/plugin-jenkins + +## 0.3.0 + +### Minor Changes + +- a41730c6e: Add tooltip for Jenkins rerun button + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI +- 6d97d2d6f: The InfoCard variant `'height100'` is deprecated. Use variant `'gridItem'` instead. + + When the InfoCard is displayed as a grid item within a grid, you may want items to have the same height for all items. + Set to the `'gridItem'` variant to display the InfoCard with full height suitable for Grid: + `...` + + Changed the InfoCards in '@backstage/plugin-github-actions', '@backstage/plugin-jenkins', '@backstage/plugin-lighthouse' + to pass an optional variant to the corresponding card of the plugin. + + As a result the overview content of the EntityPage shows cards with full height suitable for Grid. + +### Patch Changes + +- 1297dcb3a: Refactor to use DiscoveryApi +- Updated dependencies [28edd7d29] +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [368fd8243] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [f0aa01bcc] +- Updated dependencies [0aecfded0] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [8b9c8196f] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [60d40892c] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [2ebcfac8d] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [0b956f21b] +- Updated dependencies [97c2cb19b] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/plugin-catalog@0.2.0 + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 565906ca85..1d3e7f60e5 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -33,7 +33,7 @@ proxy: $env: JENKINS_BASIC_AUTH_HEADER ``` -4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password) +4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password). Here user is the name of the user created in Jenkins. ```shell HEADER=$(echo -n user:api-token | base64) @@ -61,6 +61,21 @@ spec: 8. Click the component in the catalog you should now see Jenkins builds, and a last build result for your master build. +Note: + +If you are not using environment variable then you can directly type API token in app-config.yaml + +```yaml +proxy: + '/jenkins/api': + target: 'http://localhost:8080' # your Jenkins URL + changeOrigin: true + headers: + Authorization: Basic YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw== +``` + +YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw== is the base64 of user and it's API token e.g. admin:11ec256e438501c3f5c76b751a7e47af83 + ## Features - View all runs inside a folder diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 186fdbcc33..0279592a75 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.1.1-alpha.24", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/plugin-catalog": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/core": "^0.3.1", + "@backstage/plugin-catalog": "^0.2.2", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,17 +36,17 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/testing-library__jest-dom": "^5.9.1", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 23f9af445c..5d5d7d9fcd 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import React, { FC } from 'react'; -import { Box, IconButton, Link, Typography } from '@material-ui/core'; +import { Box, IconButton, Link, Typography, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; import { generatePath, Link as RouterLink } from 'react-router-dom'; @@ -179,9 +179,11 @@ const generatedColumns: TableColumn[] = [ title: 'Actions', sorting: false, render: (row: Partial) => ( - - - + + + + + ), width: '10%', }, diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index 5034effdcf..31acedc180 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -61,12 +61,18 @@ const WidgetContent = ({ ); }; -export const LatestRunCard = ({ branch = 'master' }: { branch: string }) => { +export const LatestRunCard = ({ + branch = 'master', + variant, +}: { + branch: string; + variant?: string; +}) => { const { owner, repo } = useProjectSlugFromEntity(); const [{ builds, loading }] = useBuilds(owner, repo, branch); const lastRun = builds ?? {}; return ( - + ); diff --git a/plugins/jenkins/src/components/Router.tsx b/plugins/jenkins/src/components/Router.tsx index a4c7314691..91bcde2a8b 100644 --- a/plugins/jenkins/src/components/Router.tsx +++ b/plugins/jenkins/src/components/Router.tsx @@ -19,7 +19,7 @@ import { buildRouteRef, rootRouteRef } from '../plugin'; import { DetailedViewPage } from './BuildWithStepsPage/'; import { JENKINS_ANNOTATION } from '../constants'; import { Entity } from '@backstage/catalog-model'; -import { WarningPanel } from '@backstage/core'; +import { MissingAnnotationEmptyState } from '@backstage/core'; import { CITable } from './BuildsPage/lib/CITable'; export const isPluginApplicableToEntity = (entity: Entity) => @@ -27,10 +27,7 @@ export const isPluginApplicableToEntity = (entity: Entity) => export const Router = ({ entity }: { entity: Entity }) => { return !isPluginApplicableToEntity(entity) ? ( - -
entity.metadata.annotations['{JENKINS_ANNOTATION}']
- key is missing on the entity. -
+ ) : ( } /> diff --git a/plugins/jenkins/src/setupTests.ts b/plugins/jenkins/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/jenkins/src/setupTests.ts +++ b/plugins/jenkins/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md new file mode 100644 index 0000000000..7c55a7407d --- /dev/null +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -0,0 +1,26 @@ +# @backstage/plugin-kubernetes-backend + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [5249594c5] +- Updated dependencies [56e4eb589] +- Updated dependencies [e37c0a005] +- Updated dependencies [f00ca3cb8] +- Updated dependencies [6579769df] +- Updated dependencies [8c2b76e45] +- Updated dependencies [440a17b39] +- Updated dependencies [8afce088a] +- Updated dependencies [7bbeb049f] + - @backstage/backend-common@0.2.0 diff --git a/plugins/kubernetes-backend/README.md b/plugins/kubernetes-backend/README.md index fbbde1fe65..a9fdc14707 100644 --- a/plugins/kubernetes-backend/README.md +++ b/plugins/kubernetes-backend/README.md @@ -8,21 +8,33 @@ It responds to Kubernetes requests from the frontend. ## Configuration -### clusterLocatorMethod +### serviceLocatorMethod This configures how to determine which clusters a component is running in. -Currently, the only valid locator method is: +Currently, the only valid serviceLocatorMethod is: -#### configMultiTenant +#### multiTenant This configuration assumes that all components run on all the provided clusters. +### clusterLocatorMethods + +This is used to determine where to retrieve cluster configuration from. + +Currently, the only valid serviceLocatorMethod is: + +#### config + +This clusterLocatorMethod will read cluster information in from config + Example: ```yaml kubernetes: - clusterLocatorMethod: 'configMultiTenant' + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' clusters: - url: http://127.0.0.1:9999 name: minikube @@ -35,7 +47,7 @@ kubernetes: ##### clusters -Used by the `configMultiTenant` `clusterLocatorMethod` to construct Kubernetes clients. +Used by the `config` `clusterLocatorMethods` to construct Kubernetes clients. ###### url diff --git a/plugins/kubernetes-backend/examples/dice-roller/README.md b/plugins/kubernetes-backend/examples/dice-roller/README.md index f97f760c9f..bf3e7d261a 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/README.md +++ b/plugins/kubernetes-backend/examples/dice-roller/README.md @@ -23,11 +23,13 @@ This can be used to run the kubernetes plugin locally against a mock service. 6. Register existing component in Backstage - https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml -Update `app-config.development.yaml` as follows. +Add or update `app-config.local.yaml` with the following: ```yaml kubernetes: - clusterLocatorMethod: 'configMultiTenant' + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' clusters: - url: name: minikube @@ -43,4 +45,4 @@ Mac copy to clipboard: kubectl get secret $(kubectl get sa dice-roller -o=json | jq -r .secrets[0].name) -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy ``` -Paste into `app-config.development.yaml` `kubernetes.clusters[0].serviceAccountToken` +Paste into `app-config.local.yaml` `kubernetes.clusters[0].serviceAccountToken` diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index a44199ec8b..e58bb4f047 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.1.1-alpha.24", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", + "@backstage/backend-common": "^0.3.0", + "@backstage/config": "^0.1.1", "@kubernetes/client-node": "^0.12.1", "@types/express": "^4.17.6", "compression": "^1.7.4", @@ -37,8 +37,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "jest-fetch-mock": "^3.0.3", + "@backstage/cli": "^0.3.0", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts similarity index 82% rename from plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts rename to plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts index dd77a11bae..550703e430 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.test.ts @@ -15,10 +15,10 @@ */ import '@backstage/backend-common'; -import { MultiTenantConfigClusterLocator } from './MultiTenantConfigClusterLocator'; import { ConfigReader, Config } from '@backstage/config'; +import { ConfigClusterLocator } from './ConfigClusterLocator'; -describe('MultiTenantConfigClusterLocator', () => { +describe('ConfigClusterLocator', () => { it('empty clusters returns empty cluster details', async () => { const config: Config = new ConfigReader( { @@ -27,11 +27,11 @@ describe('MultiTenantConfigClusterLocator', () => { 'ctx', ); - const sut = MultiTenantConfigClusterLocator.fromConfig( + const sut = ConfigClusterLocator.fromConfig( config.getConfigArray('clusters'), ); - const result = await sut.getClusterByServiceId('ignored'); + const result = await sut.getClusters(); expect(result).toStrictEqual([]); }); @@ -50,11 +50,11 @@ describe('MultiTenantConfigClusterLocator', () => { 'ctx', ); - const sut = MultiTenantConfigClusterLocator.fromConfig( + const sut = ConfigClusterLocator.fromConfig( config.getConfigArray('clusters'), ); - const result = await sut.getClusterByServiceId('ignored'); + const result = await sut.getClusters(); expect(result).toStrictEqual([ { @@ -86,11 +86,11 @@ describe('MultiTenantConfigClusterLocator', () => { 'ctx', ); - const sut = MultiTenantConfigClusterLocator.fromConfig( + const sut = ConfigClusterLocator.fromConfig( config.getConfigArray('clusters'), ); - const result = await sut.getClusterByServiceId('ignored'); + const result = await sut.getClusters(); expect(result).toStrictEqual([ { diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts similarity index 65% rename from plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts rename to plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts index 5a1f0f3839..5ff4581f71 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/ConfigClusterLocator.ts @@ -14,23 +14,20 @@ * limitations under the License. */ +import { ClusterDetails, KubernetesClustersSupplier } from '..'; import { Config } from '@backstage/config'; -import { ClusterDetails, KubernetesClusterLocator } from '..'; -// This cluster locator assumes that every service is located on every cluster -// Therefore it will always return all clusters in an app configuration file -export class MultiTenantConfigClusterLocator - implements KubernetesClusterLocator { +export class ConfigClusterLocator implements KubernetesClustersSupplier { private readonly clusterDetails: ClusterDetails[]; constructor(clusterDetails: ClusterDetails[]) { this.clusterDetails = clusterDetails; } - static fromConfig(config: Config[]): MultiTenantConfigClusterLocator { + static fromConfig(config: Config[]): ConfigClusterLocator { // TODO: Add validation that authProvider is required and serviceAccountToken // is required if authProvider is serviceAccount - return new MultiTenantConfigClusterLocator( + return new ConfigClusterLocator( config.map(c => { return { name: c.getString('name'), @@ -42,9 +39,7 @@ export class MultiTenantConfigClusterLocator ); } - // As this implementation always returns all clusters serviceId is ignored here - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getClusterByServiceId(_serviceId: string): Promise { + async getClusters(): Promise { return this.clusterDetails; } } diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.test.ts b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts new file mode 100644 index 0000000000..32a09cb496 --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/index.test.ts @@ -0,0 +1,68 @@ +/* + * 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 { Config, ConfigReader } from '../../../../packages/config/src'; +import { getCombinedClusterDetails } from './index'; + +describe('getCombinedClusterDetails', () => { + it('should retrieve cluster details from config', async () => { + const config: Config = new ConfigReader( + { + kubernetes: { + clusters: [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ], + }, + }, + 'ctx', + ); + + const result = await getCombinedClusterDetails(['config'], config); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + serviceAccountToken: undefined, + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]); + }); + + it('throws an error when using an unsupported cluster locator', async () => { + await expect( + getCombinedClusterDetails(['magic' as any], new ConfigReader({}, 'ctx')), + ).rejects.toStrictEqual( + new Error('Unsupported kubernetes.clusterLocatorMethods: "magic"'), + ); + }); +}); diff --git a/plugins/kubernetes-backend/src/cluster-locator/index.ts b/plugins/kubernetes-backend/src/cluster-locator/index.ts new file mode 100644 index 0000000000..ea418768b4 --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/index.ts @@ -0,0 +1,47 @@ +/* + * 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 { ClusterDetails, ClusterLocatorMethod } from '..'; +import { Config } from '../../../../packages/config/src'; +import { ConfigClusterLocator } from './ConfigClusterLocator'; + +export { ConfigClusterLocator } from './ConfigClusterLocator'; + +export const getCombinedClusterDetails = async ( + clusterLocatorMethods: ClusterLocatorMethod[], + rootConfig: Config, +): Promise => { + return Promise.all( + clusterLocatorMethods.map(clusterLocatorMethod => { + switch (clusterLocatorMethod) { + case 'config': + return ConfigClusterLocator.fromConfig( + rootConfig.getConfigArray('kubernetes.clusters'), + ).getClusters(); + default: + throw new Error( + `Unsupported kubernetes.clusterLocatorMethods: "${clusterLocatorMethod}"`, + ); + } + }), + ) + .then(res => { + return res.flat(); + }) + .catch(e => { + throw e; + }); +}; diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts new file mode 100644 index 0000000000..13761e2671 --- /dev/null +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.test.ts @@ -0,0 +1,82 @@ +/* + * 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 '@backstage/backend-common'; +import { MultiTenantServiceLocator } from './MultiTenantServiceLocator'; + +describe('MultiTenantConfigClusterLocator', () => { + it('empty clusters returns empty cluster details', async () => { + const sut = new MultiTenantServiceLocator([]); + + const result = await sut.getClustersByServiceId('ignored'); + + expect(result).toStrictEqual([]); + }); + + it('one clusters returns one cluster details', async () => { + const sut = new MultiTenantServiceLocator([ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + serviceAccountToken: '12345', + }, + ]); + + const result = await sut.getClustersByServiceId('ignored'); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: '12345', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + ]); + }); + + it('two clusters returns two cluster details', async () => { + const sut = new MultiTenantServiceLocator([ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]); + + const result = await sut.getClustersByServiceId('ignored'); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]); + }); +}); diff --git a/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts new file mode 100644 index 0000000000..a5822a2078 --- /dev/null +++ b/plugins/kubernetes-backend/src/service-locator/MultiTenantServiceLocator.ts @@ -0,0 +1,33 @@ +/* + * 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 { ClusterDetails, KubernetesServiceLocator } from '..'; + +// This locator assumes that every service is located on every cluster +// Therefore it will always return all clusters provided +export class MultiTenantServiceLocator implements KubernetesServiceLocator { + private readonly clusterDetails: ClusterDetails[]; + + constructor(clusterDetails: ClusterDetails[]) { + this.clusterDetails = clusterDetails; + } + + // As this implementation always returns all clusters serviceId is ignored here + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getClustersByServiceId(_serviceId: string): Promise { + return this.clusterDetails; + } +} diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts index 4218c64e80..8c26ded2d4 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts @@ -22,7 +22,7 @@ const TEST_SERVICE_ID = 'my-service'; const fetchObjectsByServiceId = jest.fn(); -const getClusterByServiceId = jest.fn(); +const getClustersByServiceId = jest.fn(); const mockFetch = (mock: jest.Mock) => { mock.mockImplementation((serviceId: string, clusterDetails: ClusterDetails) => @@ -70,7 +70,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { }); it('retrieve objects for one cluster', async () => { - getClusterByServiceId.mockImplementation(() => + getClustersByServiceId.mockImplementation(() => Promise.resolve([ { name: 'test-cluster', @@ -87,13 +87,13 @@ describe('handleGetKubernetesObjectsByServiceId', () => { fetchObjectsByServiceId, }, { - getClusterByServiceId, + getClustersByServiceId, }, getVoidLogger(), {}, ); - expect(getClusterByServiceId.mock.calls.length).toBe(1); + expect(getClustersByServiceId.mock.calls.length).toBe(1); expect(fetchObjectsByServiceId.mock.calls.length).toBe(1); expect(result).toStrictEqual({ items: [ @@ -140,7 +140,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { }); it('retrieve objects for two clusters', async () => { - getClusterByServiceId.mockImplementation(() => + getClustersByServiceId.mockImplementation(() => Promise.resolve([ { name: 'test-cluster', @@ -161,7 +161,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { fetchObjectsByServiceId, }, { - getClusterByServiceId, + getClustersByServiceId, }, getVoidLogger(), { @@ -171,7 +171,7 @@ describe('handleGetKubernetesObjectsByServiceId', () => { }, ); - expect(getClusterByServiceId.mock.calls.length).toBe(1); + expect(getClustersByServiceId.mock.calls.length).toBe(1); expect(fetchObjectsByServiceId.mock.calls.length).toBe(2); expect(result).toStrictEqual({ items: [ diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts index f0b21cbc0c..6fc6092bd6 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts @@ -18,7 +18,7 @@ import { Logger } from 'winston'; import { AuthRequestBody, ClusterDetails, - KubernetesClusterLocator, + KubernetesServiceLocator, KubernetesFetcher, KubernetesObjectTypes, ObjectsByServiceIdResponse, @@ -29,7 +29,7 @@ import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator export type GetKubernetesObjectsByServiceIdHandler = ( serviceId: string, fetcher: KubernetesFetcher, - clusterLocator: KubernetesClusterLocator, + serviceLocator: KubernetesServiceLocator, logger: Logger, requestBody: AuthRequestBody, objectsToFetch?: Set, @@ -49,12 +49,12 @@ const DEFAULT_OBJECTS = new Set([ export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async ( serviceId, fetcher, - clusterLocator, + serviceLocator, logger, requestBody, objectsToFetch = DEFAULT_OBJECTS, ) => { - const clusterDetails: ClusterDetails[] = await clusterLocator.getClusterByServiceId( + const clusterDetails: ClusterDetails[] = await serviceLocator.getClustersByServiceId( serviceId, ); diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts index 4e9a206eb4..6f9fbbf70f 100644 --- a/plugins/kubernetes-backend/src/service/router.test.ts +++ b/plugins/kubernetes-backend/src/service/router.test.ts @@ -19,7 +19,7 @@ import express from 'express'; import request from 'supertest'; import { makeRouter } from './router'; import { - KubernetesClusterLocator, + KubernetesServiceLocator, KubernetesFetcher, ObjectsByServiceIdResponse, } from '..'; @@ -27,7 +27,7 @@ import { describe('router', () => { let app: express.Express; let kubernetesFetcher: jest.Mocked; - let kubernetesClusterLocator: jest.Mocked; + let kubernetesServiceLocator: jest.Mocked; let handleGetByServiceId: jest.Mock>; beforeAll(async () => { @@ -35,8 +35,8 @@ describe('router', () => { fetchObjectsByServiceId: jest.fn(), }; - kubernetesClusterLocator = { - getClusterByServiceId: jest.fn(), + kubernetesServiceLocator = { + getClustersByServiceId: jest.fn(), }; handleGetByServiceId = jest.fn(); @@ -44,7 +44,7 @@ describe('router', () => { const router = makeRouter( getVoidLogger(), kubernetesFetcher, - kubernetesClusterLocator, + kubernetesServiceLocator, handleGetByServiceId as any, ); app = express().use(router); diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts index 9e9b4ab2f5..b73115e1e4 100644 --- a/plugins/kubernetes-backend/src/service/router.ts +++ b/plugins/kubernetes-backend/src/service/router.ts @@ -18,8 +18,7 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; -import { ClusterLocatorMethod } from '../cluster-locator/types'; -import { MultiTenantConfigClusterLocator } from '../cluster-locator/MultiTenantConfigClusterLocator'; +import { MultiTenantServiceLocator } from '../service-locator/MultiTenantServiceLocator'; import { KubernetesClientBasedFetcher } from './KubernetesFetcher'; import { KubernetesClientProvider } from './KubernetesClientProvider'; import { @@ -28,30 +27,35 @@ import { } from './getKubernetesObjectsByServiceIdHandler'; import { AuthRequestBody, - KubernetesClusterLocator, + KubernetesServiceLocator, KubernetesFetcher, -} from '../types/types'; + ServiceLocatorMethod, + ClusterLocatorMethod, + ClusterDetails, +} from '..'; +import { getCombinedClusterDetails } from '../cluster-locator'; export interface RouterOptions { logger: Logger; config: Config; } -const getClusterLocator = (config: Config): KubernetesClusterLocator => { - const clusterLocatorMethod = config.getString( - 'kubernetes.clusterLocatorMethod', - ) as ClusterLocatorMethod; +const getServiceLocator = ( + config: Config, + clusterDetails: ClusterDetails[], +): KubernetesServiceLocator => { + const serviceLocatorMethod = config.getString( + 'kubernetes.serviceLocatorMethod', + ) as ServiceLocatorMethod; - switch (clusterLocatorMethod) { - case 'configMultiTenant': - return MultiTenantConfigClusterLocator.fromConfig( - config.getConfigArray('kubernetes.clusters'), - ); + switch (serviceLocatorMethod) { + case 'multiTenant': + return new MultiTenantServiceLocator(clusterDetails); case 'http': throw new Error('not implemented'); default: throw new Error( - `Unsupported kubernetes.clusterLocatorMethod "${clusterLocatorMethod}"`, + `Unsupported kubernetes.clusterLocatorMethod "${serviceLocatorMethod}"`, ); } }; @@ -59,13 +63,12 @@ const getClusterLocator = (config: Config): KubernetesClusterLocator => { export const makeRouter = ( logger: Logger, fetcher: KubernetesFetcher, - clusterLocator: KubernetesClusterLocator, + serviceLocator: KubernetesServiceLocator, handleGetByServiceId: GetKubernetesObjectsByServiceIdHandler, ): express.Router => { const router = Router(); router.use(express.json()); - // TODO error handling router.post('/services/:serviceId', async (req, res) => { const serviceId = req.params.serviceId; const requestBody: AuthRequestBody = req.body; @@ -73,7 +76,7 @@ export const makeRouter = ( const response = await handleGetByServiceId( serviceId, fetcher, - clusterLocator, + serviceLocator, logger, requestBody, ); @@ -96,17 +99,26 @@ export async function createRouter( logger.info('Initializing Kubernetes backend'); - const clusterLocator = getClusterLocator(options.config); - const fetcher = new KubernetesClientBasedFetcher({ kubernetesClientProvider: new KubernetesClientProvider(), logger, }); + const clusterLocatorMethods = options.config.getStringArray( + 'kubernetes.clusterLocatorMethods', + ) as ClusterLocatorMethod[]; + + const clusterDetails = await getCombinedClusterDetails( + clusterLocatorMethods, + options.config, + ); + + const serviceLocator = getServiceLocator(options.config, clusterDetails); + return makeRouter( logger, fetcher, - clusterLocator, + serviceLocator, handleGetKubernetesObjectsByServiceId, ); } diff --git a/plugins/kubernetes-backend/src/setupTests.ts b/plugins/kubernetes-backend/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/plugins/kubernetes-backend/src/setupTests.ts +++ b/plugins/kubernetes-backend/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index eb2817f268..1422511c21 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -118,8 +118,13 @@ export interface KubernetesFetcher { } // Used to locate which cluster(s) a service is running on -export interface KubernetesClusterLocator { - getClusterByServiceId(serviceId: string): Promise; +export interface KubernetesServiceLocator { + getClustersByServiceId(serviceId: string): Promise; +} + +// Used to load cluster details from different sources +export interface KubernetesClustersSupplier { + getClusters(): Promise; } export type KubernetesErrorTypes = @@ -132,3 +137,7 @@ export interface KubernetesFetchError { statusCode?: number; resourcePath?: string; } + +export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http +export type ClusterLocatorMethod = 'config'; +export type AuthProviderType = 'google' | 'serviceAccount'; diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md new file mode 100644 index 0000000000..e5611e4953 --- /dev/null +++ b/plugins/kubernetes/CHANGELOG.md @@ -0,0 +1,57 @@ +# @backstage/plugin-kubernetes + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI + +### Patch Changes + +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 + - @backstage/plugin-kubernetes-backend@0.1.2 diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 2adefebbd6..36954cc5b9 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.1.1-alpha.24", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,11 +20,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/plugin-kubernetes-backend": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/config": "^0.1.1", + "@backstage/core": "^0.3.1", + "@backstage/plugin-kubernetes-backend": "^0.1.3", + "@backstage/theme": "^0.2.1", "@kubernetes/client-node": "^0.12.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -35,17 +35,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", - "@backstage/test-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/kubernetes/src/Router.tsx b/plugins/kubernetes/src/Router.tsx index 41f534fbef..20a4c04285 100644 --- a/plugins/kubernetes/src/Router.tsx +++ b/plugins/kubernetes/src/Router.tsx @@ -20,7 +20,7 @@ import { Route, Routes } from 'react-router-dom'; import { rootCatalogKubernetesRouteRef } from './plugin'; import { KubernetesContent } from './components/KubernetesContent'; -import { WarningPanel } from '@backstage/core'; +import { MissingAnnotationEmptyState } from '@backstage/core'; const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; @@ -29,11 +29,7 @@ export const Router = ({ entity }: { entity: Entity }) => { entity.metadata.annotations?.[KUBERNETES_ANNOTATION]; if (!kubernetesAnnotationValue) { - return ( - -
{KUBERNETES_ANNOTATION}
annotation is missing on the entity. -
- ); + return ; } return ( diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx index 6985423a10..6fa6b3114c 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx @@ -76,7 +76,7 @@ describe('ErrorPanel', () => { expect(getByText('Cluster: THIS_CLUSTER')).toBeInTheDocument(); expect( getByText( - "Error fetching Kubernetes resource: 'some/resource', error: SYSTEM_ERROR", + "Error fetching Kubernetes resource: 'some/resource', error: SYSTEM_ERROR, status code: 500", ), ).toBeInTheDocument(); }); diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx index 1fe5714b09..1b8336dd72 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx @@ -29,7 +29,7 @@ const clustersWithErrorsToErrorMessage = ( {c.errors.map((e, j) => { return ( - {`Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}`} + {`Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}, status code: ${e.statusCode}`} ); })} diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index 013c7ad002..a3e82e79a4 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -22,7 +22,6 @@ import { configApiRef, Content, Page, - pageTheme, Progress, TabbedCard, useApi, @@ -148,7 +147,7 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? []; return ( - + {kubernetesObjects === undefined && error === undefined && ( diff --git a/plugins/kubernetes/src/setupTests.ts b/plugins/kubernetes/src/setupTests.ts index 4b4cdbdaaf..0bfa67b49a 100644 --- a/plugins/kubernetes/src/setupTests.ts +++ b/plugins/kubernetes/src/setupTests.ts @@ -14,5 +14,3 @@ * limitations under the License. */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md new file mode 100644 index 0000000000..666e7c4411 --- /dev/null +++ b/plugins/lighthouse/CHANGELOG.md @@ -0,0 +1,94 @@ +# @backstage/plugin-lighthouse + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] +- Updated dependencies [8b7737d0b] + - @backstage/core@0.3.1 + - @backstage/plugin-catalog@0.2.2 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [c5bab94ab] +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core-api@0.2.1 + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI +- 6d97d2d6f: The InfoCard variant `'height100'` is deprecated. Use variant `'gridItem'` instead. + + When the InfoCard is displayed as a grid item within a grid, you may want items to have the same height for all items. + Set to the `'gridItem'` variant to display the InfoCard with full height suitable for Grid: + `...` + + Changed the InfoCards in '@backstage/plugin-github-actions', '@backstage/plugin-jenkins', '@backstage/plugin-lighthouse' + to pass an optional variant to the corresponding card of the plugin. + + As a result the overview content of the EntityPage shows cards with full height suitable for Grid. + +### Patch Changes + +- Updated dependencies [28edd7d29] +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [cbbd271c4] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [368fd8243] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [f0aa01bcc] +- Updated dependencies [0aecfded0] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [8b9c8196f] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [60d40892c] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [2ebcfac8d] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [0b956f21b] +- Updated dependencies [26e69ab1a] +- Updated dependencies [97c2cb19b] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [cbab5bbf8] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/plugin-catalog@0.2.0 + - @backstage/core-api@0.2.0 + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/lighthouse/README.md b/plugins/lighthouse/README.md index 19244f3cf0..cdcc2fe2c4 100644 --- a/plugins/lighthouse/README.md +++ b/plugins/lighthouse/README.md @@ -23,7 +23,7 @@ _It's likely you will need to enable CORS when running lighthouse-audit-service. with the environment variable `LAS_CORS` set to `true`._ When you have an instance running that Backstage can hook into, make sure to export the plugin in -your app's [`plugins.ts`](https://github.com/spotify/backstage/blob/master/packages/app/src/plugins.ts) +your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) to enable the plugin: ```js @@ -32,7 +32,7 @@ export LighthousePlugin; ``` Then, you need to use the `lighthouseApiRef` exported from the plugin to initialize the Rest API in -your [`apis.ts`](https://github.com/spotify/backstage/blob/master/packages/app/src/apis.ts). +your [`apis.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts). ```js import { ApiHolder, ApiRegistry } from '@backstage/core'; @@ -51,7 +51,7 @@ export const apis = (config: ConfigApi) => { } ``` -Then configure the lighthouse service url in your [`app-config.yaml`](https://github.com/spotify/backstage/blob/master/app-config.yaml). +Then configure the lighthouse service url in your [`app-config.yaml`](https://github.com/backstage/backstage/blob/master/app-config.yaml). ```yaml lighthouse: diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 7446f09ed4..90230f5248 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.1.1-alpha.24", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,37 +21,51 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/core-api": "^0.1.1-alpha.24", - "@backstage/plugin-catalog": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/config": "^0.1.1", + "@backstage/core": "^0.3.1", + "@backstage/core-api": "^0.2.1", + "@backstage/plugin-catalog": "^0.2.2", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@testing-library/react-hooks": "^3.4.2", + "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-markdown": "^4.3.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", - "@types/react": "^16.9" + "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", - "@backstage/test-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/lighthouse", + "type": "object", + "properties": { + "lighthouse": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "visibility": "frontend" + } + } + } + } + } } diff --git a/plugins/lighthouse/src/Router.tsx b/plugins/lighthouse/src/Router.tsx index df481483c3..d264f3bc1b 100644 --- a/plugins/lighthouse/src/Router.tsx +++ b/plugins/lighthouse/src/Router.tsx @@ -23,7 +23,7 @@ import CreateAudit, { CreateAuditContent } from './components/CreateAudit'; import { Entity } from '@backstage/catalog-model'; import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../constants'; import { AuditListForEntity } from './components/AuditList/AuditListForEntity'; -import { EmptyState } from '@backstage/core'; +import { MissingAnnotationEmptyState } from '@backstage/core'; export const isPluginApplicableToEntity = (entity: Entity) => Boolean(entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION]); @@ -38,10 +38,8 @@ export const Router = () => ( export const EmbeddedRouter = ({ entity }: { entity: Entity }) => !isPluginApplicableToEntity(entity) ? ( - ) : ( diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx index 7f10914d9c..15a4ba3255 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -22,7 +22,6 @@ import { LighthouseRestApi, WebsiteListResponse, } from '../../api'; -import mockFetch from 'jest-fetch-mock'; import * as data from '../../__fixtures__/website-list-response.json'; import { EntityContext } from '@backstage/plugin-catalog'; @@ -53,7 +52,7 @@ describe('', () => { [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], [errorApiRef, mockErrorApi], ]); - mockFetch.mockResponse(JSON.stringify(entityWebsite)); + (useWebsiteForEntity as jest.Mock).mockReturnValue({ value: entityWebsite, loading: false, diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx index e67f939ba1..5c67fec10a 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { wrapInTestApp, msw } from '@backstage/test-utils'; import { ApiRegistry, ApiProvider } from '@backstage/core'; import AuditListTable from './AuditListTable'; @@ -26,7 +26,7 @@ import { LighthouseRestApi, } from '../../api'; import { formatTime } from '../../utils'; -import mockFetch from 'jest-fetch-mock'; +import { setupServer } from 'msw/node'; import * as data from '../../__fixtures__/website-list-response.json'; @@ -35,11 +35,13 @@ const websiteListResponse = data as WebsiteListResponse; describe('AuditListTable', () => { let apis: ApiRegistry; + const server = setupServer(); + msw.setupDefaultHandlers(server); + beforeEach(() => { apis = ApiRegistry.from([ [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], ]); - mockFetch.mockResponse(JSON.stringify(websiteListResponse)); }); const auditList = (websiteList: WebsiteListResponse) => { diff --git a/plugins/lighthouse/src/components/AuditList/index.test.tsx b/plugins/lighthouse/src/components/AuditList/index.test.tsx index 34fd760801..5fdc4781ce 100644 --- a/plugins/lighthouse/src/components/AuditList/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.test.tsx @@ -24,10 +24,9 @@ jest.mock('react-router-dom', () => { }); import React from 'react'; -import mockFetch from 'jest-fetch-mock'; import { render, fireEvent } from '@testing-library/react'; import { ApiRegistry, ApiProvider } from '@backstage/core'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { wrapInTestApp, msw } from '@backstage/test-utils'; import { lighthouseApiRef, @@ -40,18 +39,23 @@ import * as data from '../../__fixtures__/website-list-response.json'; const { useNavigate } = jest.requireMock('react-router-dom'); const websiteListResponse = data as WebsiteListResponse; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; describe('AuditList', () => { let apis: ApiRegistry; + const server = setupServer(); + msw.setupDefaultHandlers(server); + beforeEach(() => { apis = ApiRegistry.from([ [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], ]); - mockFetch.mockResponse(JSON.stringify(websiteListResponse)); }); it('should render the table', async () => { + server.use(rest.get('*', (_req, res, ctx) => res(ctx.json(data)))); const rendered = render( wrapInTestApp( @@ -76,22 +80,6 @@ describe('AuditList', () => { }); describe('pagination', () => { - it('requests the correct limit and offset from the api based on the query', () => { - mockFetch.mockClear(); - render( - wrapInTestApp( - - - , - { routeEntries: ['?page=2'] }, - ), - ); - expect(mockFetch).toHaveBeenLastCalledWith( - 'http://lighthouse/v1/websites?limit=10&offset=10', - undefined, - ); - }); - describe('when only one page is needed', () => { it('hides pagination elements', () => { const rendered = render( @@ -111,7 +99,8 @@ describe('AuditList', () => { response.limit = 5; response.offset = 5; response.total = 7; - mockFetch.mockResponseOnce(JSON.stringify(response)); + server.use(rest.get('*', (_req, res, ctx) => res(ctx.json(response)))); + server.use(rest.post('*', (_req, res, ctx) => res(ctx.json(response)))); }); it('shows pagination elements', async () => { @@ -146,7 +135,7 @@ describe('AuditList', () => { describe('when waiting on the request', () => { it('should render the loader', async () => { - mockFetch.mockResponseOnce(() => new Promise(() => {})); + server.use(rest.get('*', (_req, res, ctx) => res(ctx.delay(20000)))); const rendered = render( wrapInTestApp( @@ -161,7 +150,11 @@ describe('AuditList', () => { describe('when the audits fail', () => { it('should render an error', async () => { - mockFetch.mockRejectOnce(new Error('failed to fetch')); + server.use( + rest.get('*', (_req, res, ctx) => + res(ctx.status(500, 'something broke')), + ), + ); const rendered = render( wrapInTestApp( diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index a75bb25a0d..c6411a9baa 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import React, { useState, useMemo, FC, ReactNode } from 'react'; import { useLocalStorage, useAsync } from 'react-use'; import { useNavigate } from 'react-router-dom'; @@ -28,7 +27,6 @@ import { ContentHeader, HeaderLabel, Progress, - pageTheme, useApi, } from '@backstage/core'; @@ -95,7 +93,7 @@ const AuditList: FC<{}> = () => { } return ( - +
= () => { - {content} + {content} diff --git a/plugins/lighthouse/src/components/AuditView/index.test.tsx b/plugins/lighthouse/src/components/AuditView/index.test.tsx index 742be67fed..df223150bc 100644 --- a/plugins/lighthouse/src/components/AuditView/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.test.tsx @@ -27,29 +27,36 @@ jest.mock('react-router-dom', () => { }); import React from 'react'; -import mockFetch from 'jest-fetch-mock'; -import { render, fireEvent } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import { wrapInTestApp, msw } from '@backstage/test-utils'; import { ApiRegistry, ApiProvider } from '@backstage/core'; - import AuditView from '.'; import { lighthouseApiRef, LighthouseRestApi, Audit, Website } from '../../api'; import { formatTime } from '../../utils'; import * as data from '../../__fixtures__/website-response.json'; -import { act } from 'react-dom/test-utils'; + +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; const { useParams }: { useParams: jest.Mock } = jest.requireMock( 'react-router-dom', ); const websiteResponse = data as Website; -const { useNavigate } = jest.requireMock('react-router-dom'); describe('AuditView', () => { let apis: ApiRegistry; let id: string; + const server = setupServer(); + msw.setupDefaultHandlers(server); + beforeEach(() => { - mockFetch.mockResponse(JSON.stringify(websiteResponse)); + server.use( + rest.get('https://lighthouse/*', async (_req, res, ctx) => + res(ctx.json(websiteResponse)), + ), + ); + apis = ApiRegistry.from([ [lighthouseApiRef, new LighthouseRestApi('https://lighthouse')], ]); @@ -74,27 +81,6 @@ describe('AuditView', () => { expect(iframe).toHaveAttribute('src', `https://lighthouse/v1/audits/${id}`); }); - it('renders a button to click to create a new audit for this website', async () => { - const rendered = render( - wrapInTestApp( - - - , - ), - ); - - const button = await rendered.findByText('Create New Audit'); - expect(button).toBeInTheDocument(); - - act(() => { - fireEvent.click(button); - }); - - expect(useNavigate()).toHaveBeenCalledWith( - `../../create-audit?url=${encodeURIComponent('https://spotify.com')}`, - ); - }); - describe('sidebar', () => { it('renders a list of all audits for the website', async () => { const rendered = render( @@ -163,8 +149,8 @@ describe('AuditView', () => { }); describe('when the request for the website by id is pending', () => { - it('it shows the loading', async () => { - mockFetch.mockImplementationOnce(() => new Promise(() => {})); + it('shows the loading', async () => { + server.use(rest.get('*', (_req, res, ctx) => res(ctx.delay(20000)))); const rendered = render( wrapInTestApp( @@ -177,8 +163,12 @@ describe('AuditView', () => { }); describe('when the request for the website by id fails', () => { - it('it shows an error', async () => { - mockFetch.mockRejectOnce(new Error('failed to fetch')); + it('shows an error', async () => { + server.use( + rest.get('*', (_req, res, ctx) => + res(ctx.status(500), ctx.body('failed to fetch')), + ), + ); const rendered = render( wrapInTestApp( @@ -186,7 +176,7 @@ describe('AuditView', () => { , ), ); - expect(await rendered.findByText('failed to fetch')).toBeInTheDocument(); + expect(await rendered.findByText(/failed to fetch/)).toBeInTheDocument(); }); }); diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx index 54bd179418..ac38a329f1 100644 --- a/plugins/lighthouse/src/components/AuditView/index.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -34,7 +34,6 @@ import { import Alert from '@material-ui/lab/Alert'; import { useApi, - pageTheme, InfoCard, Header, Page, @@ -193,7 +192,7 @@ export const AuditViewContent: FC<{}> = () => { }; const ConnectedAuditView = () => ( - +
diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx index d3bc362877..3f7020cba5 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx @@ -88,9 +88,10 @@ const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({ return ; }; -export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({ - dense = false, -}) => { +export const LastLighthouseAuditCard: FC<{ + dense?: boolean; + variant?: string; +}> = ({ dense = false, variant }) => { const { value: website, loading, error } = useWebsiteForEntity(); let content; @@ -105,5 +106,9 @@ export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({ ); } - return {content}; + return ( + + {content} + + ); }; diff --git a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx index b2348bf5ff..96829926b5 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx @@ -24,20 +24,22 @@ jest.mock('react-router-dom', () => { }); import React from 'react'; -import mockFetch from 'jest-fetch-mock'; -import { wait, render, fireEvent } from '@testing-library/react'; +import { waitFor, render, fireEvent } from '@testing-library/react'; import { ApiRegistry, ApiProvider, ErrorApi, errorApiRef, } from '@backstage/core'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { wrapInTestApp, msw } from '@backstage/test-utils'; import { lighthouseApiRef, LighthouseRestApi, Audit } from '../../api'; import CreateAudit from '.'; import * as data from '../../__fixtures__/create-audit-response.json'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + const { useNavigate }: { useNavigate: jest.Mock } = jest.requireMock( 'react-router-dom', ); @@ -47,6 +49,8 @@ const createAuditResponse = data as Audit; describe('CreateAudit', () => { let apis: ApiRegistry; let errorApi: ErrorApi; + const server = setupServer(); + msw.setupDefaultHandlers(server); beforeEach(() => { errorApi = { post: jest.fn(), error$: jest.fn() }; @@ -88,7 +92,7 @@ describe('CreateAudit', () => { describe('when waiting on the request', () => { it('disables the form fields', () => { - mockFetch.mockResponseOnce(() => new Promise(() => {})); + server.use(rest.get('*', (_req, res, ctx) => res(ctx.delay(20000)))); const rendered = render( wrapInTestApp( @@ -111,7 +115,11 @@ describe('CreateAudit', () => { describe('when the audit is successfully created', () => { it('triggers a location change to the table', async () => { useNavigate.mockClear(); - mockFetch.mockResponseOnce(JSON.stringify(createAuditResponse)); + server.use( + rest.post('http://lighthouse/v1/audits', (_req, res, ctx) => + res(ctx.json(createAuditResponse)), + ), + ); const rendered = render( wrapInTestApp( @@ -126,14 +134,7 @@ describe('CreateAudit', () => { }); fireEvent.click(rendered.getByText(/Create Audit/)); - expect(mockFetch).toHaveBeenCalledWith( - 'http://lighthouse/v1/audits', - expect.objectContaining({ - method: 'POST', - }), - ); - - await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); + await waitFor(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); expect(useNavigate()).toHaveBeenCalledWith('..'); }); @@ -141,9 +142,11 @@ describe('CreateAudit', () => { describe('when the audits fail', () => { it('should render an error', async () => { - (errorApi.post as jest.Mock).mockClear(); - mockFetch.mockRejectOnce(new Error('failed to post')); - + server.use( + rest.post('http://lighthouse/v1/audits', (_req, res, ctx) => + res(ctx.status(500, 'failed to post')), + ), + ); const rendered = render( wrapInTestApp( @@ -157,8 +160,7 @@ describe('CreateAudit', () => { }); fireEvent.click(rendered.getByText(/Create Audit/)); - await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); - await new Promise(r => setTimeout(r, 0)); + await waitFor(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); expect(errorApi.post).toHaveBeenCalledWith(expect.any(Error)); }); diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx index ad46b06020..55800d79ec 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx @@ -30,7 +30,6 @@ import { InfoCard, Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -43,6 +42,9 @@ import LighthouseSupportButton from '../SupportButton'; const useStyles = makeStyles(theme => ({ input: { minWidth: 300, + [theme.breakpoints.down('xs')]: { + minWidth: '100%', + }, }, buttonList: { marginLeft: theme.spacing(-1), @@ -50,6 +52,14 @@ const useStyles = makeStyles(theme => ({ '& > *': { margin: theme.spacing(1), }, + [theme.breakpoints.down('xs')]: { + marginLeft: 0, + marginRight: 0, + flexDirection: 'column', + '& > *': { + width: '100%', + }, + }, }, })); @@ -170,7 +180,7 @@ export const CreateAuditContent: FC<{}> = () => { }; const CreateAudit = () => ( - +
diff --git a/plugins/lighthouse/src/components/Intro/index.tsx b/plugins/lighthouse/src/components/Intro/index.tsx index ee5d5d66af..3e969a3c74 100644 --- a/plugins/lighthouse/src/components/Intro/index.tsx +++ b/plugins/lighthouse/src/components/Intro/index.tsx @@ -15,8 +15,7 @@ */ import React, { useState } from 'react'; import { useLocalStorage } from 'react-use'; -import Markdown from 'react-markdown'; -import { ContentHeader, InfoCard } from '@backstage/core'; +import { ContentHeader, InfoCard, MarkdownContent } from '@backstage/core'; import { makeStyles, Button, Grid, Tabs, Tab } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; @@ -43,7 +42,7 @@ _It's likely you will need to enable CORS when running lighthouse-audit-service. with the environment variable \`LAS_CORS\` set to \`true\`._ When you have an instance running that Backstage can hook into, make sure to export the plugin in -your app's [\`plugins.ts\`](https://github.com/spotify/backstage/blob/master/packages/app/src/plugins.ts) +your app's [\`plugins.ts\`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts) to enable the plugin: \`\`\`js @@ -52,7 +51,7 @@ export LighthousePlugin; \`\`\` Then, you need to use the \`lighthouseApiRef\` exported from the plugin to initialize the Rest API in -your [\`apis.ts\`](https://github.com/spotify/backstage/blob/master/packages/app/src/apis.ts). +your [\`apis.ts\`](https://github.com/backstage/backstage/blob/master/packages/app/src/apis.ts). \`\`\`js import { ApiHolder, ApiRegistry } from '@backstage/core'; @@ -116,8 +115,8 @@ function GettingStartedCard() { } > - {value === 0 && } - {value === 1 && } + {value === 0 && } + {value === 1 && } ); } diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index 868e26f04c..580162f182 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -75,7 +75,7 @@ describe('useWebsiteForEntity', () => { (mockLighthouseApi.getWebsiteByUrl as jest.Mock).mockResolvedValue(website); }); - it('returns the lighthouse information for the website url in annotations ', async () => { + it('returns the lighthouse information for the website url in annotations', async () => { const { result, waitForNextUpdate } = subject(); await waitForNextUpdate(); expect(result.current?.value).toBe(website); diff --git a/plugins/lighthouse/src/setupTests.ts b/plugins/lighthouse/src/setupTests.ts index 8553642152..aea2220869 100644 --- a/plugins/lighthouse/src/setupTests.ts +++ b/plugins/lighthouse/src/setupTests.ts @@ -15,5 +15,4 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); +import 'cross-fetch/polyfill'; diff --git a/plugins/newrelic/CHANGELOG.md b/plugins/newrelic/CHANGELOG.md new file mode 100644 index 0000000000..86b71d497e --- /dev/null +++ b/plugins/newrelic/CHANGELOG.md @@ -0,0 +1,68 @@ +# @backstage/plugin-newrelic + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI +- 4512b9967: The New Relic plugin now uses the Backstage proxy to communicate with New Relic's API. + + Please update your `app-config.yaml` as follows: + + ```yaml + # Old Config + newrelic: + api: + baseUrl: 'https://api.newrelic.com/v2' + key: NEW_RELIC_REST_API_KEY + ``` + + ```yaml + # New Config + proxy: + '/newrelic/apm/api': + target: https://api.newrelic.com/v2 + headers: + X-Api-Key: + $env: NEW_RELIC_REST_API_KEY + ``` + +### Patch Changes + +- Updated dependencies [819a70229] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/core@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/newrelic/README.md b/plugins/newrelic/README.md index 19bd24950c..e14acedef2 100644 --- a/plugins/newrelic/README.md +++ b/plugins/newrelic/README.md @@ -7,16 +7,38 @@ Website: [https://newrelic.com](https://newrelic.com) ## Getting Started -Add New Relic REST API Key to `app-config.yaml` +This plugin uses the Backstage proxy to securely communicate with New Relic's +APIs. Add the following to your `app-config.yaml` to enable this configuration: ```yaml -newrelic: - api: - baseUrl: 'https://api.newrelic.com/v2' - key: +proxy: + '/newrelic/apm/api': + target: https://api.newrelic.com/v2 + headers: + X-Api-Key: + $env: NEW_RELIC_REST_API_KEY ``` -New Relic Plugin Path: [/newrelic](http://localhost:3000/newrelic) +In your production deployment of Backstage, you would also need to ensure that +you've set the `NEW_RELIC_REST_API_KEY` environment variable before starting +the backend. + +While working locally, you may wish to hard-code your API key in your +`app-config.local.yaml` like this: + +```yaml +# app-config.local.yaml +proxy: + '/newrelic/apm/api': + headers: + X-Api-Key: NRRA-YourActualApiKey +``` + +Read more about how to find or generate this key in +[New Relic's Documentation](https://docs.newrelic.com/docs/apis/get-started/intro-apis/types-new-relic-api-keys#rest-api-key). + +See if it's working by visiting the New Relic Plugin Path: +[/newrelic](http://localhost:3000/newrelic) ## Features diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 603d01d62b..0e1f4e1352 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-newrelic", - "version": "0.1.1-alpha.24", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/core": "^0.3.1", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -31,16 +31,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/newrelic/src/api/index.ts b/plugins/newrelic/src/api/index.ts new file mode 100644 index 0000000000..d76a87875c --- /dev/null +++ b/plugins/newrelic/src/api/index.ts @@ -0,0 +1,110 @@ +/* + * 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, DiscoveryApi } from '@backstage/core'; + +export type NewRelicApplication = { + id: number; + application_summary: NewRelicApplicationSummary; + name: string; + language: string; + health_status: string; + reporting: boolean; + settings: NewRelicApplicationSettings; + links?: NewRelicApplicationLinks; +}; + +export type NewRelicApplicationSummary = { + apdex_score: number; + error_rate: number; + host_count: number; + instance_count: number; + response_time: number; + throughput: number; +}; + +export type NewRelicApplicationSettings = { + app_apdex_threshold: number; + end_user_apdex_threshold: number; + enable_real_user_monitoring: boolean; + use_server_side_config: boolean; +}; + +export type NewRelicApplicationLinks = { + application_instances: Array; + servers: Array; + application_hosts: Array; +}; + +export type NewRelicApplications = { + applications: NewRelicApplication[]; +}; + +export const newRelicApiRef = createApiRef({ + id: 'plugin.newrelic.service', + description: 'Used by the NewRelic plugin to make requests', +}); + +const DEFAULT_PROXY_PATH_BASE = '/newrelic'; + +type Options = { + discoveryApi: DiscoveryApi; + /** + * Path to use for requests via the proxy, defaults to /newrelic + */ + proxyPathBase?: string; +}; + +export interface NewRelicApi { + getApplications(): Promise; +} + +export class NewRelicClient implements NewRelicApi { + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPathBase: string; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.proxyPathBase = options.proxyPathBase ?? DEFAULT_PROXY_PATH_BASE; + } + + async getApplications(): Promise { + const url = await this.getApiUrl('apm', 'applications.json'); + const response = await fetch(url); + let responseJson; + + try { + responseJson = await response.json(); + } catch (e) { + responseJson = { applications: [] }; + } + + if (response.status !== 200) { + throw new Error( + `Error communicating with New Relic: ${ + responseJson?.error?.title || response.statusText + }`, + ); + } + + return responseJson; + } + + private async getApiUrl(product: string, path: string) { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return `${proxyUrl}${this.proxyPathBase}/${product}/api/${path}`; + } +} diff --git a/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx b/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx index 94d5c530a1..81e28a8c80 100644 --- a/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx +++ b/plugins/newrelic/src/components/NewRelicComponent/NewRelicComponent.tsx @@ -19,7 +19,6 @@ import { Grid } from '@material-ui/core'; import { Header, Page, - pageTheme, Content, ContentHeader, HeaderLabel, @@ -28,7 +27,7 @@ import { import NewRelicFetchComponent from '../NewRelicFetchComponent'; const NewRelicComponent: FC<{}> = () => ( - +
diff --git a/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx b/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx index 9b3791e062..62f52e7875 100644 --- a/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx +++ b/plugins/newrelic/src/components/NewRelicFetchComponent/NewRelicFetchComponent.tsx @@ -15,52 +15,10 @@ */ import React, { FC } from 'react'; -import { - configApiRef, - Progress, - Table, - TableColumn, - useApi, -} from '@backstage/core'; +import { Progress, Table, TableColumn, useApi } from '@backstage/core'; import Alert from '@material-ui/lab/Alert'; import { useAsync } from 'react-use'; - -type NewRelicApplication = { - id: number; - application_summary: NewRelicApplicationSummary; - name: string; - language: string; - health_status: string; - reporting: boolean; - settings: NewRelicApplicationSettings; - links?: NewRelicApplicationLinks; -}; - -type NewRelicApplicationSummary = { - apdex_score: number; - error_rate: number; - host_count: number; - instance_count: number; - response_time: number; - throughput: number; -}; - -type NewRelicApplicationSettings = { - app_apdex_threshold: number; - end_user_apdex_threshold: number; - enable_real_user_monitoring: boolean; - use_server_side_config: boolean; -}; - -type NewRelicApplicationLinks = { - application_instances: Array; - servers: Array; - application_hosts: Array; -}; - -type NewRelicApplications = { - applications: NewRelicApplication[]; -}; +import { newRelicApiRef, NewRelicApplications } from '../../api'; export const NewRelicAPMTable: FC = ({ applications, @@ -73,7 +31,7 @@ export const NewRelicAPMTable: FC = ({ { title: 'Instance Count', field: 'instanceCount' }, { title: 'Apdex', field: 'apdexScore' }, ]; - const data = applications.map((app: NewRelicApplication) => { + const data = applications.map(app => { const { name, application_summary: applicationSummary } = app; const { response_time: responseTime, @@ -104,20 +62,11 @@ export const NewRelicAPMTable: FC = ({ }; const NewRelicFetchComponent: FC<{}> = () => { - const configApi = useApi(configApiRef); - const apiBaseUrl = configApi.getString('newrelic.api.baseUrl'); - const apiKey = configApi.getString('newrelic.api.key'); + const api = useApi(newRelicApiRef); - const { value, loading, error } = useAsync(async (): Promise< - NewRelicApplication[] - > => { - const response = await fetch(`${apiBaseUrl}/applications.json`, { - headers: { - 'X-Api-Key': apiKey, - }, - }); - const data: NewRelicApplications = await response.json(); - return data.applications.filter((application: NewRelicApplication) => { + const { value, loading, error } = useAsync(async () => { + const data = await api.getApplications(); + return data.applications.filter(application => { return application.hasOwnProperty('application_summary'); }); }, []); diff --git a/plugins/newrelic/src/plugin.ts b/plugins/newrelic/src/plugin.ts index 21aac69e8f..5f5ba88617 100644 --- a/plugins/newrelic/src/plugin.ts +++ b/plugins/newrelic/src/plugin.ts @@ -14,7 +14,13 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; +import { + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, +} from '@backstage/core'; +import { NewRelicClient, newRelicApiRef } from './api'; import NewRelicComponent from './components/NewRelicComponent'; export const rootRouteRef = createRouteRef({ @@ -24,6 +30,13 @@ export const rootRouteRef = createRouteRef({ export const plugin = createPlugin({ id: 'newrelic', + apis: [ + createApiFactory({ + api: newRelicApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new NewRelicClient({ discoveryApi }), + }), + ], register({ router }) { router.addRoute(rootRouteRef, NewRelicComponent); }, diff --git a/plugins/newrelic/src/setupTests.ts b/plugins/newrelic/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/newrelic/src/setupTests.ts +++ b/plugins/newrelic/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/proxy-backend/CHANGELOG.md b/plugins/proxy-backend/CHANGELOG.md new file mode 100644 index 0000000000..b034d26c95 --- /dev/null +++ b/plugins/proxy-backend/CHANGELOG.md @@ -0,0 +1,52 @@ +# @backstage/plugin-proxy-backend + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + +## 0.2.0 + +### Minor Changes + +- 5249594c5: Add service discovery interface and implement for single host deployments + + Fixes #1847, #2596 + + Went with an interface similar to the frontend DiscoveryApi, since it's dead simple but still provides a lot of flexibility in the implementation. + + Also ended up with two different methods, one for internal endpoint discovery and one for external. The two use-cases are explained a bit more in the docs, but basically it's service-to-service vs callback URLs. + + This did get me thinking about uniqueness and that we're heading towards a global namespace for backend plugin IDs. That's probably fine, but if we're happy with that we should leverage it a bit more to simplify the backend setup. For example we'd have each plugin provide its own ID and not manually mount on paths in the backend. + + Draft until we're happy with the implementation, then I can add more docs and changelog entry. Also didn't go on a thorough hunt for places where discovery can be used, but I don't think there are many since it's been pretty awkward to do service-to-service communication. + +- 9226c2aaa: Limit the http headers that are forwarded from the request to a safe set of defaults. + A user can configure additional headers that should be forwarded if the specific applications needs that. + + ```yaml + proxy: + '/my-api': + target: 'https://my-api.com/get' + allowedHeaders: + # We need to forward the Authorization header that was provided by the caller + - Authorization + ``` + +### Patch Changes + +- Updated dependencies [5249594c5] +- Updated dependencies [56e4eb589] +- Updated dependencies [e37c0a005] +- Updated dependencies [f00ca3cb8] +- Updated dependencies [6579769df] +- Updated dependencies [8c2b76e45] +- Updated dependencies [440a17b39] +- Updated dependencies [8afce088a] +- Updated dependencies [7bbeb049f] + - @backstage/backend-common@0.2.0 diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 723c3e395e..22acdd3fe8 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-proxy-backend", - "version": "0.1.1-alpha.24", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,28 +19,25 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", + "@backstage/backend-common": "^0.3.0", + "@backstage/config": "^0.1.1", "@types/express": "^4.17.6", - "@types/http-proxy-middleware": "^0.19.3", "express": "^4.17.1", "express-promise-router": "^3.0.3", "http-proxy-middleware": "^0.19.1", "morgan": "^1.10.0", - "node-fetch": "^2.6.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yaml": "^1.9.2", "yn": "^4.0.0", - "yup": "^0.29.1" + "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@types/node-fetch": "^2.5.7", + "@backstage/cli": "^0.3.0", + "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", - "@types/yup": "^0.28.2", - "jest-fetch-mock": "^3.0.3", + "@types/yup": "^0.29.8", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/proxy-backend/src/index.ts b/plugins/proxy-backend/src/index.ts index 7612c392a2..de2be19b6d 100644 --- a/plugins/proxy-backend/src/index.ts +++ b/plugins/proxy-backend/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './service/router'; +export * from './service'; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/index.ts b/plugins/proxy-backend/src/service/index.ts similarity index 92% rename from plugins/cost-insights/src/components/CostOverviewChart/index.ts rename to plugins/proxy-backend/src/service/index.ts index 21f345a282..38fbb697c4 100644 --- a/plugins/cost-insights/src/components/CostOverviewChart/index.ts +++ b/plugins/proxy-backend/src/service/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './CostOverviewChart'; +export { createRouter } from './router'; diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index 07737e9ea8..77db35c917 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -14,18 +14,34 @@ * limitations under the License. */ -import { createRouter } from './router'; +import { buildMiddleware, createRouter } from './router'; import * as winston from 'winston'; -import { ConfigReader } from '@backstage/config'; import { loadBackendConfig, SingleHostDiscovery, } from '@backstage/backend-common'; +import createProxyMiddleware, { + Config as ProxyMiddlewareConfig, + Proxy, +} from 'http-proxy-middleware'; +import * as http from 'http'; + +jest.mock('http-proxy-middleware', () => { + return jest.fn().mockImplementation( + (): Proxy => { + return () => undefined; + }, + ); +}); + +const mockCreateProxyMiddleware = createProxyMiddleware as jest.MockedFunction< + typeof createProxyMiddleware +>; describe('createRouter', () => { it('works', async () => { const logger = winston.createLogger(); - const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const config = await loadBackendConfig({ logger, argv: [] }); const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ config, @@ -35,3 +51,151 @@ describe('createRouter', () => { expect(router).toBeDefined(); }); }); + +describe('buildMiddleware', () => { + const logger = winston.createLogger(); + + beforeEach(() => { + mockCreateProxyMiddleware.mockClear(); + }); + + it('accepts strings', async () => { + buildMiddleware('/api/', logger, 'test', 'http://mocked'); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + ProxyMiddlewareConfig, + ]; + expect(filter('', { method: 'GET' })).toBe(true); + expect(filter('', { method: 'POST' })).toBe(true); + expect(filter('', { method: 'PUT' })).toBe(true); + expect(filter('', { method: 'PATCH' })).toBe(true); + expect(filter('', { method: 'DELETE' })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + + it('limits allowedMethods', async () => { + buildMiddleware('/api/', logger, 'test', { + target: 'http://mocked', + allowedMethods: ['GET', 'DELETE'], + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const [filter, fullConfig] = mockCreateProxyMiddleware.mock.calls[0] as [ + (pathname: string, req: Partial) => boolean, + ProxyMiddlewareConfig, + ]; + expect(filter('', { method: 'GET' })).toBe(true); + expect(filter('', { method: 'POST' })).toBe(false); + expect(filter('', { method: 'PUT' })).toBe(false); + expect(filter('', { method: 'PATCH' })).toBe(false); + expect(filter('', { method: 'DELETE' })).toBe(true); + + expect(fullConfig.pathRewrite).toEqual({ '^/api/test/': '/' }); + expect(fullConfig.changeOrigin).toBe(true); + expect(fullConfig.logProvider!(logger)).toBe(logger); + }); + + it('permits default headers', async () => { + buildMiddleware('/api/', logger, 'test', { + target: 'http://mocked', + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const config = mockCreateProxyMiddleware.mock + .calls[0][1] as ProxyMiddlewareConfig; + + const testClientRequest = { + getHeaderNames: () => [ + 'cache-control', + 'content-language', + 'content-length', + 'content-type', + 'expires', + 'last-modified', + 'pragma', + 'host', + 'accept', + 'accept-language', + 'user-agent', + 'cookie', + ], + removeHeader: jest.fn(), + } as Partial; + + expect(config).toBeDefined(); + expect(config.onProxyReq).toBeDefined(); + + config.onProxyReq!( + testClientRequest as http.ClientRequest, + {} as http.IncomingMessage, + {} as http.ServerResponse, + ); + + expect(testClientRequest.removeHeader).toHaveBeenCalledTimes(1); + expect(testClientRequest.removeHeader).toHaveBeenCalledWith('cookie'); + }); + + it('permits default and configured headers', async () => { + buildMiddleware('/api/', logger, 'test', { + target: 'http://mocked', + headers: { + Authorization: 'my-token', + }, + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const config = mockCreateProxyMiddleware.mock + .calls[0][1] as ProxyMiddlewareConfig; + + const testClientRequest = { + getHeaderNames: () => ['authorization', 'Cookie'], + removeHeader: jest.fn(), + } as Partial; + + config.onProxyReq!( + testClientRequest as http.ClientRequest, + {} as http.IncomingMessage, + {} as http.ServerResponse, + ); + + expect(testClientRequest.removeHeader).toHaveBeenCalledTimes(1); + expect(testClientRequest.removeHeader).toHaveBeenCalledWith('Cookie'); + }); + + it('permits configured headers', async () => { + buildMiddleware('/api/', logger, 'test', { + target: 'http://mocked', + allowedHeaders: ['authorization', 'cookie'], + }); + + expect(createProxyMiddleware).toHaveBeenCalledTimes(1); + + const config = mockCreateProxyMiddleware.mock + .calls[0][1] as ProxyMiddlewareConfig; + + const testClientRequest = { + getHeaderNames: () => ['authorization', 'Cookie', 'X-Auth-Request-User'], + removeHeader: jest.fn(), + } as Partial; + + config.onProxyReq!( + testClientRequest as http.ClientRequest, + {} as http.IncomingMessage, + {} as http.ServerResponse, + ); + + expect(testClientRequest.removeHeader).toHaveBeenCalledTimes(1); + expect(testClientRequest.removeHeader).toHaveBeenCalledWith( + 'X-Auth-Request-User', + ); + }); +}); diff --git a/plugins/proxy-backend/src/service/router.ts b/plugins/proxy-backend/src/service/router.ts index 8cec31719c..75eab029b5 100644 --- a/plugins/proxy-backend/src/service/router.ts +++ b/plugins/proxy-backend/src/service/router.ts @@ -25,6 +25,27 @@ import { Logger } from 'winston'; import http from 'http'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; +// A list of headers that are always forwarded to the proxy targets. +const safeForwardHeaders = [ + // https://fetch.spec.whatwg.org/#cors-safelisted-request-header + 'cache-control', + 'content-language', + 'content-length', + 'content-type', + 'expires', + 'last-modified', + 'pragma', + + // host is overridden by default. if changeOrigin is configured to false, + // we assume this is a intentional and should also be forwarded. + 'host', + + // other headers that we assume to be ok + 'accept', + 'accept-language', + 'user-agent', +]; + export interface RouterOptions { logger: Logger; config: Config; @@ -33,11 +54,12 @@ export interface RouterOptions { export interface ProxyConfig extends ProxyMiddlewareConfig { allowedMethods?: string[]; + allowedHeaders?: string[]; } // Creates a proxy middleware, possibly with defaults added on top of the // given config. -function buildMiddleware( +export function buildMiddleware( pathPrefix: string, logger: Logger, route: string, @@ -68,6 +90,30 @@ function buildMiddleware( return fullConfig?.allowedMethods?.includes(req.method!) ?? true; }; + // Only forward the allowed HTTP headers to not forward unwanted secret headers + const headerAllowList = new Set( + [ + // allow all safe headers + ...safeForwardHeaders, + + // allow all headers that are set by the proxy + ...((fullConfig.headers && Object.keys(fullConfig.headers)) || []), + + // allow all configured headers + ...(fullConfig.allowedHeaders || []), + ].map(h => h.toLocaleLowerCase()), + ); + + fullConfig.onProxyReq = (proxyReq: http.ClientRequest) => { + const headerNames = proxyReq.getHeaderNames(); + + headerNames.forEach(h => { + if (!headerAllowList.has(h.toLocaleLowerCase())) { + proxyReq.removeHeader(h); + } + }); + }; + return createProxyMiddleware(filter, fullConfig); } diff --git a/plugins/proxy-backend/src/service/standaloneServer.ts b/plugins/proxy-backend/src/service/standaloneServer.ts index 83980729d8..c64d69e2a4 100644 --- a/plugins/proxy-backend/src/service/standaloneServer.ts +++ b/plugins/proxy-backend/src/service/standaloneServer.ts @@ -22,7 +22,6 @@ import { import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; -import { ConfigReader } from '@backstage/config'; export interface ServerOptions { port: number; @@ -37,7 +36,7 @@ export async function startStandaloneServer( logger.debug('Creating application...'); - const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const config = await loadBackendConfig({ logger, argv: process.argv }); const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ config, diff --git a/plugins/proxy-backend/src/setupTests.ts b/plugins/proxy-backend/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/plugins/proxy-backend/src/setupTests.ts +++ b/plugins/proxy-backend/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/plugins/register-component/CHANGELOG.md b/plugins/register-component/CHANGELOG.md new file mode 100644 index 0000000000..3e966ef0d0 --- /dev/null +++ b/plugins/register-component/CHANGELOG.md @@ -0,0 +1,72 @@ +# @backstage/plugin-register-component + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI +- 2ebcfac8d: Add a validate button to the register-component page + + This allows the user to validate his location before adding it. + +### Patch Changes + +- 8b9c8196f: Locations registered through the catalog client now default to the 'url' type. The type selection dropdown in the register-component form has been removed. +- Updated dependencies [28edd7d29] +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [368fd8243] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [f0aa01bcc] +- Updated dependencies [0aecfded0] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [8b9c8196f] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [60d40892c] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [2ebcfac8d] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [0b956f21b] +- Updated dependencies [97c2cb19b] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/plugin-catalog@0.2.0 + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 9dbd598dee..06ae17cb0e 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "version": "0.1.1-alpha.24", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/plugin-catalog": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/core": "^0.3.1", + "@backstage/plugin-catalog": "^0.2.2", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,16 +36,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx index 10c3d9fcd1..fae5d649b9 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx @@ -20,17 +20,18 @@ import React from 'react'; import { RegisterComponentForm } from './RegisterComponentForm'; describe('RegisterComponentForm', () => { - it('should initially render a disabled button', async () => { + it('should initially render disabled buttons', async () => { render(); expect( await screen.findByText(/Enter the full path to the catalog-info.yaml/), ).toBeInTheDocument(); - expect(screen.getByText('Submit').closest('button')).toBeDisabled(); + expect(screen.getByText('Validate').closest('button')).toBeDisabled(); + expect(screen.getByText('Register').closest('button')).toBeDisabled(); }); - it('should enable a submit button when the target url is set ', async () => { + it('should enable the submit buttons when the target url is set', async () => { render(); await act(async () => { @@ -40,7 +41,8 @@ describe('RegisterComponentForm', () => { ); }); - expect(screen.getByText('Submit').closest('button')).not.toBeDisabled(); + expect(screen.getByText('Validate').closest('button')).not.toBeDisabled(); + expect(screen.getByText('Register').closest('button')).not.toBeDisabled(); }); it('should show spinner while submitting', async () => { diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx index bc433e1c36..de183869f7 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx @@ -19,15 +19,12 @@ import { Button, FormControl, FormHelperText, - InputLabel, LinearProgress, - MenuItem, - Select, TextField, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import React from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { ComponentIdValidators } from '../../util/validate'; const useStyles = makeStyles(theme => ({ @@ -36,8 +33,11 @@ const useStyles = makeStyles(theme => ({ display: 'flex', flexFlow: 'column nowrap', }, - submit: { - marginTop: theme.spacing(1), + buttonSpacing: { + marginLeft: theme.spacing(1), + }, + buttons: { + marginTop: theme.spacing(2), }, select: { minWidth: 120, @@ -50,19 +50,28 @@ export type Props = { }; export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { - const { control, register, handleSubmit, errors, formState } = useForm({ + const { register, handleSubmit, errors, formState } = useForm({ mode: 'onChange', }); const classes = useStyles(); - const hasErrors = !!errors.componentLocation; + const hasErrors = !!errors.entityLocation; const dirty = formState?.isDirty; + const onSubmitValidate = handleSubmit(data => { + data.mode = 'validate'; + onSubmit(data); + }); + + const onSubmitRegister = handleSubmit(data => { + data.mode = 'register'; + onSubmit(data); + }); + return submitting ? ( ) : (
@@ -71,10 +80,9 @@ export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { id="registerComponentInput" variant="outlined" label="Entity file URL" - data-testid="componentLocationInput" error={hasErrors} placeholder="https://example.com/user/some-service/blob/master/catalog-info.yaml" - name="componentLocation" + name="entityLocation" required margin="normal" helperText="Enter the full path to the catalog-info.yaml file in GitHub, GitLab, Bitbucket or Azure to start tracking your component." @@ -84,45 +92,34 @@ export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { })} /> - {errors.componentLocation && ( + {errors.entityLocation && ( - {errors.componentLocation.message} + {errors.entityLocation.message} )} - - Host type - ( - - )} - /> - - +
+ + +
); }; diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index e5496d1ea5..6b097085e9 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -35,7 +35,7 @@ const errorApi: jest.Mocked = { const catalogApi: jest.Mocked = { /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - addLocation: jest.fn((_a, _b) => new Promise(() => {})), + addLocation: jest.fn(_a => new Promise(() => {})), getEntities: jest.fn(), getLocationByEntity: jest.fn(), getLocationById: jest.fn(), diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index 933b67060a..d0b56598b2 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -19,7 +19,6 @@ import { Grid, makeStyles } from '@material-ui/core'; import { InfoCard, Page, - pageTheme, Content, useApi, errorApiRef, @@ -76,44 +75,36 @@ export const RegisterComponentPage = ({ location: Location; } | null; error: null | Error; + dryRun: boolean; }>({ data: null, error: null, + dryRun: false, }); const handleSubmit = async (formData: Record) => { setFormState(FormStates.Submitting); - const { scmType, componentLocation: target } = formData; + const { entityLocation: target, mode } = formData; + const dryRun = mode === 'validate'; try { - const typeMapping = [ - { url: /^https:\/\/gitlab\.com\/.*/, type: 'gitlab/api' }, - { url: /^https:\/\/bitbucket\.org\/.*/, type: 'bitbucket/api' }, - { url: /^https:\/\/dev\.azure\.com\/.*/, type: 'azure/api' }, - { url: /.*/, type: 'github' }, - ]; - - const type = - scmType === 'AUTO' - ? typeMapping.filter(item => item.url.test(target))[0].type - : scmType; - const data = await catalogApi.addLocation(type, target); + const data = await catalogApi.addLocation({ target, dryRun }); if (!isMounted()) return; - setResult({ error: null, data }); + setResult({ error: null, data, dryRun }); setFormState(FormStates.Success); } catch (e) { errorApi.post(e); if (!isMounted()) return; - setResult({ error: e, data: null }); + setResult({ error: e, data: null, dryRun }); setFormState(FormStates.Idle); } }; return ( - +
@@ -136,6 +127,7 @@ export const RegisterComponentPage = ({ {formState === FormStates.Success && ( setFormState(FormStates.Idle)} classes={{ paper: classes.dialogPaper }} catalogRouteRef={catalogRouteRef} diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index c75235c6cb..c31d1344cb 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -14,29 +14,30 @@ * limitations under the License. */ -import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { RouteRef, StructuredMetadataTable } from '@backstage/core'; +import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; import { + Button, Dialog, - DialogTitle, + DialogActions, DialogContent, DialogContentText, + DialogTitle, + Divider, + Link, List, ListItem, - Link, - Divider, - DialogActions, - Button, } from '@material-ui/core'; -import { Entity } from '@backstage/catalog-model'; -import { StructuredMetadataTable, RouteRef } from '@backstage/core'; +import React, { useState } from 'react'; import { generatePath, resolvePath } from 'react-router'; -import { entityRoute } from '@backstage/plugin-catalog'; import { Link as RouterLink } from 'react-router-dom'; type Props = { onClose: () => void; classes?: Record; entities: Entity[]; + dryRun?: boolean; catalogRouteRef: RouteRef; }; @@ -47,22 +48,16 @@ const getEntityCatalogPath = ({ entity: Entity; catalogRouteRef: RouteRef; }) => { - const optionalNamespaceAndName = [ - entity.metadata.namespace, - entity.metadata.name, - ] - .filter(Boolean) - .join(':'); - - const relativeEntityPathInsideCatalog = generatePath(entityRoute.path, { - optionalNamespaceAndName, - kind: entity.kind, - }); + const relativeEntityPathInsideCatalog = generatePath( + entityRoute.path, + entityRouteParams(entity), + ); const resolvedAbsolutePath = resolvePath( relativeEntityPathInsideCatalog, catalogRouteRef.path, )?.pathname; + return resolvedAbsolutePath; }; @@ -70,49 +65,73 @@ export const RegisterComponentResultDialog = ({ onClose, classes, entities, + dryRun, catalogRouteRef, -}: Props) => ( - - Registration Result - - - The following entities have been successfully created: - - - {entities.map((entity: any, index: number) => { - const entityPath = getEntityCatalogPath({ entity, catalogRouteRef }); - return ( - - - - {entityPath} - - ), - }} - /> - - {index < entities.length - 1 && } - - ); - })} - - - - - - -); +}: Props) => { + const [open, setOpen] = useState(true); + const handleClose = () => { + setOpen(false); + }; + + return ( + + + {dryRun ? 'Validation Result' : 'Registration Result'} + + + + {dryRun + ? 'The following entities would be created:' + : 'The following entities have been successfully created:'} + + + {entities.map((entity: any, index: number) => { + const entityPath = getEntityCatalogPath({ + entity, + catalogRouteRef, + }); + return ( + + + + {entityPath} + + ), + }} + /> + + {index < entities.length - 1 && } + + ); + })} + + + + {dryRun && ( + + )} + {!dryRun && ( + + )} + + + ); +}; diff --git a/plugins/register-component/src/setupTests.ts b/plugins/register-component/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/register-component/src/setupTests.ts +++ b/plugins/register-component/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/rollbar-backend/CHANGELOG.md b/plugins/rollbar-backend/CHANGELOG.md new file mode 100644 index 0000000000..ec042b9ae1 --- /dev/null +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -0,0 +1,26 @@ +# @backstage/plugin-rollbar-backend + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [5249594c5] +- Updated dependencies [56e4eb589] +- Updated dependencies [e37c0a005] +- Updated dependencies [f00ca3cb8] +- Updated dependencies [6579769df] +- Updated dependencies [8c2b76e45] +- Updated dependencies [440a17b39] +- Updated dependencies [8afce088a] +- Updated dependencies [7bbeb049f] + - @backstage/backend-common@0.2.0 diff --git a/plugins/rollbar-backend/README.md b/plugins/rollbar-backend/README.md index f441efa416..8f13eb5f26 100644 --- a/plugins/rollbar-backend/README.md +++ b/plugins/rollbar-backend/README.md @@ -8,7 +8,7 @@ The following values are read from the configuration file. ```yaml rollbar: - organization: spotify + organization: organization-name accountToken: $env: ROLLBAR_ACCOUNT_TOKEN ``` @@ -18,5 +18,5 @@ access account token._ ## Links -- [Frontend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/rollbar) +- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/rollbar) - [The Backstage homepage](https://backstage.io) diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index cda105a73f..307c37e0da 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar-backend", - "version": "0.1.1-alpha.24", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", + "@backstage/backend-common": "^0.3.0", + "@backstage/config": "^0.1.1", "@types/express": "^4.17.6", "axios": "^0.20.0", "camelcase-keys": "^6.2.2", @@ -37,9 +37,8 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", "@types/supertest": "^2.0.8", - "jest-fetch-mock": "^3.0.3", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/rollbar-backend/src/service/standaloneServer.ts b/plugins/rollbar-backend/src/service/standaloneServer.ts index a96d30d836..b30bf6fc6b 100644 --- a/plugins/rollbar-backend/src/service/standaloneServer.ts +++ b/plugins/rollbar-backend/src/service/standaloneServer.ts @@ -20,7 +20,6 @@ import { createServiceBuilder, loadBackendConfig, } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; import { createRouter } from './router'; export interface ServerOptions { @@ -33,7 +32,7 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'rollbar-backend' }); - const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const config = await loadBackendConfig({ logger, argv: process.argv }); logger.debug('Creating application...'); diff --git a/plugins/rollbar-backend/src/setupTests.ts b/plugins/rollbar-backend/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/plugins/rollbar-backend/src/setupTests.ts +++ b/plugins/rollbar-backend/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md new file mode 100644 index 0000000000..e4a6671f90 --- /dev/null +++ b/plugins/rollbar/CHANGELOG.md @@ -0,0 +1,78 @@ +# @backstage/plugin-rollbar + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] +- Updated dependencies [8b7737d0b] + - @backstage/core@0.3.1 + - @backstage/plugin-catalog@0.2.2 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI + +### Patch Changes + +- Updated dependencies [28edd7d29] +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [368fd8243] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [f0aa01bcc] +- Updated dependencies [0aecfded0] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [8b9c8196f] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [60d40892c] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [2ebcfac8d] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [0b956f21b] +- Updated dependencies [97c2cb19b] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/plugin-catalog@0.2.0 + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/rollbar/README.md b/plugins/rollbar/README.md index 98f2272508..a232462dde 100644 --- a/plugins/rollbar/README.md +++ b/plugins/rollbar/README.md @@ -4,7 +4,7 @@ Website: [https://rollbar.com/](https://rollbar.com/) ## Setup -1. Configure the [rollbar backend plugin](https://github.com/spotify/backstage/tree/master/plugins/rollbar-backend/README.md) +1. Configure the [rollbar backend plugin](https://github.com/backstage/backstage/tree/master/plugins/rollbar-backend/README.md) 2. If you have standalone app (you didn't clone this repo), then do @@ -53,7 +53,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( ```yaml # app.config.yaml rollbar: - organization: spotify + organization: organization-name accountToken: $env: ROLLBAR_ACCOUNT_TOKEN ``` @@ -83,5 +83,5 @@ metadata: ## Links -- [Backend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/rollbar-backend) +- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/rollbar-backend) - [The Backstage homepage](https://backstage.io) diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index ba690fde64..c107573ca8 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.1.1-alpha.24", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/plugin-catalog": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/core": "^0.3.1", + "@backstage/plugin-catalog": "^0.2.2", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -37,9 +37,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", - "@backstage/test-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", @@ -47,11 +47,26 @@ "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react": "^16.9", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/rollbar", + "type": "object", + "properties": { + "rollbar": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "visibility": "frontend" + } + } + } + } + } } diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx index c6ee052c0e..ff3e909bf7 100644 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx +++ b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx @@ -52,7 +52,7 @@ describe('RollbarHome component', () => { catalogApiRef, ({ async getEntities() { - return []; + return { items: [] }; }, } as Partial) as CatalogApi, ], diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx index d09c2054b1..fe99e70575 100644 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx +++ b/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { Content, Header, Page, pageTheme } from '@backstage/core'; +import { Content, Header, Page } from '@backstage/core'; import { RollbarProjectTable } from '../RollbarProjectTable/RollbarProjectTable'; import { useRollbarEntities } from '../../hooks/useRollbarEntities'; @@ -23,7 +23,7 @@ export const RollbarHome = () => { const { entities, loading, error } = useRollbarEntities(); return ( - +
{ const { entity } = useCatalogEntity(); return ( - +
diff --git a/plugins/rollbar/src/components/Router.tsx b/plugins/rollbar/src/components/Router.tsx index 192ef33a61..f0756cb02a 100644 --- a/plugins/rollbar/src/components/Router.tsx +++ b/plugins/rollbar/src/components/Router.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Routes, Route } from 'react-router'; import { Entity } from '@backstage/catalog-model'; -import { WarningPanel } from '@backstage/core'; +import { MissingAnnotationEmptyState } from '@backstage/core'; import { catalogRouteRef } from '../routes'; import { ROLLBAR_ANNOTATION } from '../constants'; import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar'; @@ -31,9 +31,7 @@ type Props = { export const Router = ({ entity }: Props) => !isPluginApplicableToEntity(entity) ? ( - -
{ROLLBAR_ANNOTATION}
annotation is missing on the entity. -
+ ) : ( { - const entities = await catalogApi.getEntities(); - return entities.filter(entity => { + const response = await catalogApi.getEntities(); + return response.items.filter(entity => { return !!entity.metadata.annotations?.[ROLLBAR_ANNOTATION]; }); }, [catalogApi]); diff --git a/plugins/rollbar/src/setupTests.ts b/plugins/rollbar/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/rollbar/src/setupTests.ts +++ b/plugins/rollbar/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md new file mode 100644 index 0000000000..a388a29a00 --- /dev/null +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -0,0 +1,78 @@ +# @backstage/plugin-scaffolder-backend + +## 0.3.1 + +### Patch Changes + +- d33f5157c: Extracted pushToRemote function for reuse between publishers +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + +## 0.3.0 + +### Minor Changes + +- 59166e5ec: `createRouter` of scaffolder backend will now require additional option as `entityClient` which could be generated by `CatalogEntityClient` in `plugin-scaffolder-backend` package. Here is example to generate `entityClient`. + + ```js + import { CatalogEntityClient } from '@backstage/plugin-scaffolder-backend'; + import { SingleHostDiscovery } from '@backstage/backend-common'; + + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + ``` + + - Scaffolder's API `/v1/jobs` will accept `templateName` instead of `template` Entity. + +### Patch Changes + +- Updated dependencies [33b7300eb] + - @backstage/backend-common@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 3e254503d: Add Azure DevOps support to the scaffolder backend + + This adds support for Azure DevOps to the scaffolder (preparer & publisher). I thought I should get this in there now since #2426 has been merged. I had a previous PR with only the preparer but I closed that in favor of this one. + + I stayed with the 'azure/api' structure but I guess we should try and go the same way as with GitHub here #2501 + +### Patch Changes + +- 0c370c979: Update SSR template to pass CI +- 991a950e0: Added .fromConfig static factories for Preparers and Publishers + read integrations config to support url location types +- c926765a2: Allow templates to be located on non-default branch +- 6840a68df: Add authentication token to Scaffolder GitHub Preparer +- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub enterprise instances +- 5e4551e3a: Added support for configuring the working directory of the Scaffolder: + + ```yaml + backend: + workingDirectory: /some-dir # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir + ``` + +- e3d063ffa: Introduce PreparerOptions for PreparerBase +- Updated dependencies [3a4236570] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [5249594c5] +- Updated dependencies [56e4eb589] +- Updated dependencies [e37c0a005] +- Updated dependencies [a768a07fb] +- Updated dependencies [f00ca3cb8] +- Updated dependencies [6579769df] +- Updated dependencies [5adfc005e] +- Updated dependencies [8c2b76e45] +- Updated dependencies [440a17b39] +- Updated dependencies [fa56f4615] +- Updated dependencies [8afce088a] +- Updated dependencies [b3d57961c] +- Updated dependencies [7bbeb049f] + - @backstage/catalog-model@0.2.0 + - @backstage/backend-common@0.2.0 diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index ff584e7374..73c07d158f 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.1.1-alpha.24", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,11 +20,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.24", - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", - "@gitbeaker/core": "^23.5.0", - "@gitbeaker/node": "^23.5.0", + "@backstage/backend-common": "^0.3.0", + "@backstage/catalog-model": "^0.2.0", + "@backstage/config": "^0.1.1", + "@gitbeaker/core": "^25.2.0", + "@gitbeaker/node": "^25.2.0", "@octokit/rest": "^18.0.0", "@types/dockerode": "^2.5.32", "@types/express": "^4.17.6", @@ -36,7 +36,7 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.2.0", + "git-url-parse": "^11.4.0", "globby": "^11.0.0", "helmet": "^4.0.0", "jsonschema": "^1.2.6", @@ -44,14 +44,15 @@ "nodegit": "0.27.0", "uuid": "^8.2.0", "winston": "^3.2.1", - "yaml": "^1.10.0" + "yaml": "^1.10.0", + "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", "@octokit/types": "^5.4.1", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", - "@types/nodegit": "0.26.8", + "@types/nodegit": "0.26.11", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", "yaml": "^1.10.0" diff --git a/plugins/scaffolder-backend/sample-templates/all-templates.yaml b/plugins/scaffolder-backend/sample-templates/all-templates.yaml index 1eb619a072..6866543ab0 100644 --- a/plugins/scaffolder-backend/sample-templates/all-templates.yaml +++ b/plugins/scaffolder-backend/sample-templates/all-templates.yaml @@ -6,8 +6,8 @@ metadata: spec: type: github targets: - - https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml - - https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml - - https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml + - https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml + - https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml + - https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml - https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml - - https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml + - https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml diff --git a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml index 2c8b3b3d6d..10109dee3d 100644 --- a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml @@ -22,6 +22,7 @@ spec: component_id: title: Name type: string + pattern: ^[a-z0-9]+(-[a-z0-9]+)*$ description: Unique name of the component. Lowercase, URL-safe characters only. description: title: Description diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml index 31b57135ad..36404f088c 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml @@ -9,13 +9,13 @@ metadata: - techdocs - mkdocs spec: - owner: spotify/techdocs-core + owner: backstage/techdocs-core templater: cookiecutter type: documentation path: '.' - + schema: - required: + required: - component_id - description properties: @@ -27,4 +27,3 @@ spec: title: Description type: string description: Help others understand what these docs are about. - diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml index 854b692fcf..e39350e928 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/component-info.yaml @@ -1,12 +1,12 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: {{cookiecutter.component_id}} - description: {{cookiecutter.description}} + name: {{cookiecutter.component_id | jsonify}} + description: {{cookiecutter.description | jsonify}} annotations: - github.com/project-slug: {{cookiecutter.storePath}} + github.com/project-slug: {{cookiecutter.storePath | jsonify}} backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} spec: type: documentation lifecycle: experimental - owner: {{cookiecutter.owner}} + owner: {{cookiecutter.owner | jsonify}} diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml index 2126971502..64f1e9ee8e 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/mkdocs.yml @@ -1,7 +1,7 @@ -site_name: {{cookiecutter.component_id}} -site_description: {{cookiecutter.description}} +site_name: {{cookiecutter.component_id | jsonify}} +site_description: {{cookiecutter.description | jsonify}} -nav: +nav: - Introduction: index.md plugins: diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml index f6e1d2c8d9..423c8683a1 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml @@ -1,12 +1,12 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: {{cookiecutter.component_id}} - description: {{cookiecutter.description}} + name: {{cookiecutter.component_id | jsonify}} + description: {{cookiecutter.description | jsonify}} annotations: - github.com/project-slug: {{cookiecutter.storePath}} + github.com/project-slug: {{cookiecutter.storePath | jsonify}} backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} spec: type: website lifecycle: experimental - owner: {{cookiecutter.owner}} + owner: {{cookiecutter.owner | jsonify}} diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml index 0d10d11063..64f1e9ee8e 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/mkdocs.yml @@ -1,5 +1,5 @@ -site_name: {{cookiecutter.component_id}} -site_description: {{cookiecutter.description}} +site_name: {{cookiecutter.component_id | jsonify}} +site_description: {{cookiecutter.description | jsonify}} nav: - Introduction: index.md diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml index 05b87c9c0c..f4ff350d10 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml @@ -1,12 +1,12 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: - name: {{cookiecutter.component_id}} - description: {{cookiecutter.description}} + name: {{cookiecutter.component_id | jsonify}} + description: {{cookiecutter.description | jsonify}} annotations: - github.com/project-slug: {{cookiecutter.storePath}} + github.com/project-slug: {{cookiecutter.storePath | jsonify}} backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} spec: type: service lifecycle: experimental - owner: {{cookiecutter.owner}} + owner: {{cookiecutter.owner | jsonify}} diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml index 0d10d11063..64f1e9ee8e 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/mkdocs.yml @@ -1,5 +1,5 @@ -site_name: {{cookiecutter.component_id}} -site_description: {{cookiecutter.description}} +site_name: {{cookiecutter.component_id | jsonify}} +site_description: {{cookiecutter.description | jsonify}} nav: - Introduction: index.md diff --git a/plugins/scaffolder-backend/src/index.ts b/plugins/scaffolder-backend/src/index.ts index c461bfede6..28e6a2b24c 100644 --- a/plugins/scaffolder-backend/src/index.ts +++ b/plugins/scaffolder-backend/src/index.ts @@ -16,3 +16,4 @@ export * from './scaffolder'; export * from './service/router'; +export * from './lib/catalog'; diff --git a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts new file mode 100644 index 0000000000..4541f03a4a --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -0,0 +1,72 @@ +/* + * 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 fetch from 'cross-fetch'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { + ConflictError, + NotFoundError, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; + +/** + * A catalog client tailored for reading out entity data from the catalog. + */ +export class CatalogEntityClient { + private readonly discovery: PluginEndpointDiscovery; + + constructor(options: { discovery: PluginEndpointDiscovery }) { + this.discovery = options.discovery; + } + + /** + * Looks up a single template using a template name. + * + * Throws a NotFoundError or ConflictError if 0 or multiple templates are found. + */ + async findTemplate(templateName: string): Promise { + const conditions = [ + 'kind=template', + `metadata.name=${encodeURIComponent(templateName)}`, + ]; + + const baseUrl = await this.discovery.getBaseUrl('catalog'); + const response = await fetch( + `${baseUrl}/entities?filter=${conditions.join(',')}`, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Request failed with ${response.status} ${response.statusText}, ${text}`, + ); + } + + const templates: TemplateEntityV1alpha1[] = await response.json(); + + if (templates.length !== 1) { + if (templates.length > 1) { + throw new ConflictError( + 'Templates lookup resulted in multiple matches', + ); + } else { + throw new NotFoundError('Template not found'); + } + } + + return templates[0]; + } +} diff --git a/plugins/scaffolder-backend/src/lib/catalog/index.ts b/plugins/scaffolder-backend/src/lib/catalog/index.ts new file mode 100644 index 0000000000..a8de546e00 --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/catalog/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { CatalogEntityClient } from './CatalogEntityClient'; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index 8a875e4e2b..d23db937bb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -61,7 +61,7 @@ describe('JobProcessor', () => { const mockValues: RequiredTemplateValues = { owner: 'blobby', - storePath: 'spotify/mock-repo', + storePath: 'backstage/mock-repo', }; describe('create', () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index c39677232d..f1dbb24dd7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -118,8 +118,8 @@ export class JobProcessor implements Processor { stage.status = 'COMPLETED'; } catch (error) { // Log to the current stage the error that occured and fail the stage. - logger.error(`Stage failed with error: ${error.message}`); stage.status = 'FAILED'; + logger.error(`Stage failed with error: ${error.message}`); // Throw the error so the job can be failed too. throw error; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts index 8b174a1edf..09bd6b7885 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts @@ -13,11 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { parseLocationAnnotation } from './helpers'; +import { + makeDeprecatedLocationTypeDetector, + parseLocationAnnotation, +} from './helpers'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; describe('Helpers', () => { describe('parseLocationAnnotation', () => { @@ -253,4 +257,29 @@ describe('Helpers', () => { }); }); }); + + describe('makeDeprecatedLocationTypeDetector', () => { + it('detects deprecated location types', () => { + const detector = makeDeprecatedLocationTypeDetector( + new ConfigReader({ + integrations: { + github: [{ host: 'derp.com' }, { host: 'foo.com' }], + gitlab: [{ host: 'derp.org' }, { host: 'foo.org' }], + azure: [{ host: 'derp.net' }, { host: 'foo.net' }], + }, + }), + ); + + expect(detector('http://lol:wut@derp.com/wat')).toBe('github'); + expect(detector('https://foo.com/wat')).toBe('github'); + expect(detector('http://derp.org:80/wat')).toBe('gitlab'); + expect(detector('https://foo.org/wat')).toBe('gitlab'); + expect(detector('http://not.derp.net')).toBe(undefined); + expect(detector('http://derp.net')).toBe('azure/api'); + expect(detector('http://derp.net:8080/wat')).toBe('azure/api'); + expect(detector('http://github.com')).toBe('github'); + expect(detector('http://gitlab.com')).toBe('gitlab'); + expect(detector('http://dev.azure.com')).toBe('azure/api'); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts index 3f31f19d59..e54e796b3b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts @@ -17,6 +17,7 @@ import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; import { InputError } from '@backstage/backend-common'; import { RemoteProtocol } from './types'; @@ -54,3 +55,39 @@ export const parseLocationAnnotation = ( location, }; }; + +export type DeprecatedLocationTypeDetector = ( + url: string, +) => string | undefined; + +// The reason for the existence of this is to help in migration to using mostly locations +// of type 'url'. This allows us to detect the deprecated location type based on the host, +// which we in turn can use to select out preparer or publisher. +// +// TODO(Rugvip): This should be removed in the future once we fully migrate to using +// integrations configuration for the scaffolder. +export function makeDeprecatedLocationTypeDetector( + config: Config, +): DeprecatedLocationTypeDetector { + const hostMap = new Map(); + + // These are installed by default by the integrations + hostMap.set('github.com', 'github'); + hostMap.set('gitlab.com', 'gitlab'); + hostMap.set('dev.azure.com', 'azure/api'); + + config.getOptionalConfigArray('integrations.github')?.forEach(sub => { + hostMap.set(sub.getString('host'), 'github'); + }); + config.getOptionalConfigArray('integrations.gitlab')?.forEach(sub => { + hostMap.set(sub.getString('host'), 'gitlab'); + }); + config.getOptionalConfigArray('integrations.azure')?.forEach(sub => { + hostMap.set(sub.getString('host'), 'azure/api'); + }); + + return (url: string): string | undefined => { + const parsed = new URL(url); + return hostMap.get(parsed.hostname); + }; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 7a7973e981..db3a110384 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -19,12 +19,18 @@ const mocks = { CheckoutOptions: jest.fn(() => {}), }; jest.doMock('nodegit', () => mocks); +jest.doMock('fs-extra', () => ({ + promises: { + mkdtemp: jest.fn(dir => `${dir}-static`), + }, +})); import { AzurePreparer } from './azure'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; +import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; describe('AzurePreparer', () => { @@ -73,7 +79,7 @@ describe('AzurePreparer', () => { it('calls the clone command with the correct arguments for a repository', async () => { const preparer = new AzurePreparer(ConfigReader.fromConfigs([])); - await preparer.prepare(mockEntity); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mocks.Clone.clone).toHaveBeenNthCalledWith( 1, 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', @@ -99,7 +105,7 @@ describe('AzurePreparer', () => { }, ]), ); - await preparer.prepare(mockEntity); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mocks.Clone.clone).toHaveBeenNthCalledWith( 1, 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', @@ -117,7 +123,7 @@ describe('AzurePreparer', () => { it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { const preparer = new AzurePreparer(ConfigReader.fromConfigs([])); delete mockEntity.spec.path; - await preparer.prepare(mockEntity); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mocks.Clone.clone).toHaveBeenNthCalledWith( 1, 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', @@ -129,10 +135,25 @@ describe('AzurePreparer', () => { it('return the temp directory with the path to the folder if it is specified', async () => { const preparer = new AzurePreparer(ConfigReader.fromConfigs([])); mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity); + const response = await preparer.prepare(mockEntity, { + logger: getVoidLogger(), + }); expect(response.split('\\').join('/')).toMatch( /\/template\/test\/1\/2\/3$/, ); }); + + it('return the working directory with the path to the folder if it is specified', async () => { + const preparer = new AzurePreparer(ConfigReader.fromConfigs([])); + mockEntity.spec.path = './template/test/1/2/3'; + const response = await preparer.prepare(mockEntity, { + logger: getVoidLogger(), + workingDirectory: '/workDir', + }); + + expect(response.split('\\').join('/')).toMatch( + /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + ); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts index c2cb97e505..333bf9116c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import os from 'os'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { parseLocationAnnotation } from '../helpers'; import { InputError } from '@backstage/backend-common'; -import { PreparerBase } from './types'; +import { PreparerBase, PreparerOptions } from './types'; import GitUriParser from 'git-url-parse'; import { Clone, Cred } from 'nodegit'; import { Config } from '@backstage/config'; @@ -32,28 +32,28 @@ export class AzurePreparer implements PreparerBase { config.getOptionalString('scaffolder.azure.api.token') ?? ''; } - async prepare(template: TemplateEntityV1alpha1): Promise { + async prepare( + template: TemplateEntityV1alpha1, + opts: PreparerOptions, + ): Promise { const { protocol, location } = parseLocationAnnotation(template); + const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - if (protocol !== 'azure/api') { + if (!['azure/api', 'url'].includes(protocol)) { throw new InputError( - `Wrong location protocol: ${protocol}, should be 'azure/api'`, + `Wrong location protocol: ${protocol}, should be 'url'`, ); } const templateId = template.metadata.name; - const url = new URL(location); // Need to extract filepath from search parameter const parsedGitLocation = GitUriParser(location); const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), templateId), + path.join(workingDirectory, templateId), ); const templateDirectory = path.join( - `${path - .dirname(url.searchParams.get('path') || '') - .replace(/^\/+/g, '')}`, // Strip leading slash + `${path.dirname(parsedGitLocation.filepath)}`, template.spec.path ?? '.', ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts index b9472fbc8d..2b328f420b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import os from 'os'; import fs from 'fs-extra'; import YAML from 'yaml'; import { FilePreparer } from './file'; @@ -22,6 +23,7 @@ import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; +import { getVoidLogger } from '@backstage/backend-common'; const setupTest = async (fixturePath: string) => { const locationForTemplateYaml = path.resolve( @@ -42,7 +44,10 @@ const setupTest = async (fixturePath: string) => { }; const filePreparer = new FilePreparer(); - const resultDir = await filePreparer.prepare(template); + const resultDir = await filePreparer.prepare(template, { + logger: getVoidLogger(), + workingDirectory: os.tmpdir(), + }); return { filePreparer, template, resultDir }; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts index b5ac846cf2..08129c7397 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts @@ -13,17 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import os from 'os'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { parseLocationAnnotation } from '../helpers'; import { InputError } from '@backstage/backend-common'; -import { PreparerBase } from './types'; +import { PreparerBase, PreparerOptions } from './types'; export class FilePreparer implements PreparerBase { - async prepare(template: TemplateEntityV1alpha1): Promise { + async prepare( + template: TemplateEntityV1alpha1, + opts: PreparerOptions, + ): Promise { const { protocol, location } = parseLocationAnnotation(template); + const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); if (protocol !== 'file') { throw new InputError( @@ -33,7 +37,7 @@ export class FilePreparer implements PreparerBase { const templateId = template.metadata.name; const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), templateId), + path.join(workingDirectory, templateId), ); const parentDirectory = path.resolve( diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index 4409d815b5..86a870330e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -19,12 +19,18 @@ const mocks = { CheckoutOptions: jest.fn(() => {}), }; jest.doMock('nodegit', () => mocks); +jest.doMock('fs-extra', () => ({ + promises: { + mkdtemp: jest.fn(dir => `${dir}-static`), + }, +})); import { GithubPreparer } from './github'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; +import { getVoidLogger } from '@backstage/backend-common'; describe('GitHubPreparer', () => { let mockEntity: TemplateEntityV1alpha1; @@ -71,33 +77,70 @@ describe('GitHubPreparer', () => { }); it('calls the clone command with the correct arguments for a repository', async () => { const preparer = new GithubPreparer(); - await preparer.prepare(mockEntity); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mocks.Clone.clone).toHaveBeenNthCalledWith( 1, 'https://github.com/benjdlambert/backstage-graphql-template', expect.any(String), - {}, + { + checkoutBranch: 'master', + }, ); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { const preparer = new GithubPreparer(); delete mockEntity.spec.path; - await preparer.prepare(mockEntity); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mocks.Clone.clone).toHaveBeenNthCalledWith( 1, 'https://github.com/benjdlambert/backstage-graphql-template', expect.any(String), - {}, + { + checkoutBranch: 'master', + }, ); }); it('return the temp directory with the path to the folder if it is specified', async () => { const preparer = new GithubPreparer(); mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity); + const response = await preparer.prepare(mockEntity, { + logger: getVoidLogger(), + }); expect(response.split('\\').join('/')).toMatch( /\/template\/test\/1\/2\/3$/, ); }); + + it('return the working directory with the path to the folder if it is specified', async () => { + const preparer = new GithubPreparer(); + mockEntity.spec.path = './template/test/1/2/3'; + const response = await preparer.prepare(mockEntity, { + logger: getVoidLogger(), + workingDirectory: '/workDir', + }); + + expect(response.split('\\').join('/')).toMatch( + /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + ); + }); + + it('calls the clone command with the token when provided', async () => { + const preparer = new GithubPreparer({ token: 'abc' }); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + expect(mocks.Clone.clone).toHaveBeenNthCalledWith( + 1, + 'https://github.com/benjdlambert/backstage-graphql-template', + expect.any(String), + { + checkoutBranch: 'master', + fetchOpts: { + callbacks: { + credentials: expect.any(Function), + }, + }, + }, + ); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index ba4adcabd9..e6b41907be 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -13,23 +13,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import os from 'os'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { parseLocationAnnotation } from '../helpers'; import { InputError } from '@backstage/backend-common'; -import { PreparerBase } from './types'; +import { PreparerBase, PreparerOptions } from './types'; import GitUriParser from 'git-url-parse'; -import { Clone } from 'nodegit'; +import { Clone, CloneOptions, Cred } from 'nodegit'; export class GithubPreparer implements PreparerBase { - async prepare(template: TemplateEntityV1alpha1): Promise { - const { protocol, location } = parseLocationAnnotation(template); + token?: string; - if (protocol !== 'github') { + constructor(params: { token?: string } = {}) { + this.token = params.token; + } + + async prepare( + template: TemplateEntityV1alpha1, + opts: PreparerOptions, + ): Promise { + const { protocol, location } = parseLocationAnnotation(template); + const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); + const { token } = this; + + if (!['github', 'url'].includes(protocol)) { throw new InputError( - `Wrong location protocol: ${protocol}, should be 'github'`, + `Wrong location protocol: ${protocol}, should be 'url'`, ); } const templateId = template.metadata.name; @@ -37,7 +48,7 @@ export class GithubPreparer implements PreparerBase { const parsedGitLocation = GitUriParser(location); const repositoryCheckoutUrl = parsedGitLocation.toString('https'); const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), templateId), + path.join(workingDirectory, templateId), ); const templateDirectory = path.join( @@ -45,9 +56,24 @@ export class GithubPreparer implements PreparerBase { template.spec.path ?? '.', ); - await Clone.clone(repositoryCheckoutUrl, tempDir, { - // TODO(blam): Maybe need some auth here? - }); + let cloneOptions: CloneOptions = { + checkoutBranch: parsedGitLocation.ref, + }; + + if (token) { + cloneOptions = { + ...cloneOptions, + fetchOpts: { + callbacks: { + credentials() { + return Cred.userpassPlaintextNew(token, 'x-oauth-basic'); + }, + }, + }, + }; + } + + await Clone.clone(repositoryCheckoutUrl, tempDir, cloneOptions); return path.resolve(tempDir, templateDirectory); } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index b25886b027..a86a51e0dc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -18,6 +18,11 @@ const mocks = { CheckoutOptions: jest.fn(() => {}), }; jest.doMock('nodegit', () => mocks); +jest.doMock('fs-extra', () => ({ + promises: { + mkdtemp: jest.fn(dir => `${dir}-static`), + }, +})); import { GitlabPreparer } from './gitlab'; import { @@ -25,6 +30,7 @@ import { LOCATION_ANNOTATION, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; const mockEntityWithProtocol = (protocol: string): TemplateEntityV1alpha1 => ({ apiVersion: 'backstage.io/v1alpha1', @@ -74,7 +80,7 @@ describe('GitLabPreparer', () => { it(`calls the clone command with the correct arguments for a repository using the ${protocol} protocol`, async () => { const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); mockEntity = mockEntityWithProtocol(protocol); - await preparer.prepare(mockEntity); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mocks.Clone.clone).toHaveBeenNthCalledWith( 1, 'https://gitlab.com/benjdlambert/backstage-graphql-template', @@ -101,7 +107,7 @@ describe('GitLabPreparer', () => { ]), ); mockEntity = mockEntityWithProtocol(protocol); - await preparer.prepare(mockEntity); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mocks.Clone.clone).toHaveBeenNthCalledWith( 1, 'https://gitlab.com/benjdlambert/backstage-graphql-template', @@ -120,7 +126,7 @@ describe('GitLabPreparer', () => { const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); mockEntity = mockEntityWithProtocol(protocol); delete mockEntity.spec.path; - await preparer.prepare(mockEntity); + await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mocks.Clone.clone).toHaveBeenNthCalledWith( 1, 'https://gitlab.com/benjdlambert/backstage-graphql-template', @@ -133,11 +139,26 @@ describe('GitLabPreparer', () => { const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); mockEntity = mockEntityWithProtocol(protocol); mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity); + const response = await preparer.prepare(mockEntity, { + logger: getVoidLogger(), + }); expect(response.split('\\').join('/')).toMatch( /\/template\/test\/1\/2\/3$/, ); }); + + it('return the working directory with the path to the folder if it is specified', async () => { + const preparer = new GitlabPreparer(ConfigReader.fromConfigs([])); + mockEntity.spec.path = './template/test/1/2/3'; + const response = await preparer.prepare(mockEntity, { + logger: getVoidLogger(), + workingDirectory: '/workDir', + }); + + expect(response.split('\\').join('/')).toMatch( + /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + ); + }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index 8efc158a96..605c8db2df 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import os from 'os'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { parseLocationAnnotation } from '../helpers'; import { InputError } from '@backstage/backend-common'; -import { PreparerBase } from './types'; +import { PreparerBase, PreparerOptions } from './types'; import GitUriParser from 'git-url-parse'; import { Clone, Cred } from 'nodegit'; import { Config } from '@backstage/config'; @@ -33,21 +33,24 @@ export class GitlabPreparer implements PreparerBase { ''; } - async prepare(template: TemplateEntityV1alpha1): Promise { + async prepare( + template: TemplateEntityV1alpha1, + opts: PreparerOptions, + ): Promise { const { protocol, location } = parseLocationAnnotation(template); + const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - if (['gitlab', 'gitlab/api'].indexOf(protocol) < 0) { + if (!['gitlab', 'gitlab/api', 'url'].includes(protocol)) { throw new InputError( - `Wrong location protocol: ${protocol}, should be 'gitlab' or 'gitlab/api'`, + `Wrong location protocol: ${protocol}, should be 'url'`, ); } const templateId = template.metadata.name; const parsedGitLocation = GitUriParser(location); const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(os.tmpdir(), templateId), + path.join(workingDirectory, templateId), ); const templateDirectory = path.join( diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts index 0aaef2633d..7c2d7e0a47 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts @@ -24,7 +24,7 @@ describe('Preparers', () => { metadata: { annotations: { 'backstage.io/managed-by-location': - 'file:/Users/bingo/spotify/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml', + 'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml', }, name: 'react-ssr-template', title: 'React SSR Template', diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts index 5efb3ad60d..226f7e3836 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts @@ -14,26 +14,85 @@ * limitations under the License. */ +import { Config } from '@backstage/config'; +import { Logger } from 'winston'; import { PreparerBase, PreparerBuilder } from './types'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; +import { + DeprecatedLocationTypeDetector, + makeDeprecatedLocationTypeDetector, + parseLocationAnnotation, +} from '../helpers'; import { RemoteProtocol } from '../types'; +import { FilePreparer } from './file'; +import { GitlabPreparer } from './gitlab'; +import { AzurePreparer } from './azure'; +import { GithubPreparer } from './github'; export class Preparers implements PreparerBuilder { private preparerMap = new Map(); + constructor(private readonly typeDetector?: DeprecatedLocationTypeDetector) {} + register(protocol: RemoteProtocol, preparer: PreparerBase) { this.preparerMap.set(protocol, preparer); } get(template: TemplateEntityV1alpha1): PreparerBase { - const { protocol } = parseLocationAnnotation(template); + const { protocol, location } = parseLocationAnnotation(template); + const preparer = this.preparerMap.get(protocol); if (!preparer) { + if ((protocol as string) === 'url') { + const type = this.typeDetector?.(location); + const detected = type && this.preparerMap.get(type as RemoteProtocol); + if (detected) { + return detected; + } + throw new Error(`No preparer integration found for url "${location}"`); + } throw new Error(`No preparer registered for type: "${protocol}"`); } return preparer; } + + static async fromConfig( + config: Config, + { logger }: { logger: Logger }, + ): Promise { + const typeDetector = makeDeprecatedLocationTypeDetector(config); + + const preparers = new Preparers(typeDetector); + + const filePreparer = new FilePreparer(); + const gitlabPreparer = new GitlabPreparer(config); + const azurePreparer = new AzurePreparer(config); + + preparers.register('file', filePreparer); + preparers.register('gitlab', gitlabPreparer); + preparers.register('gitlab/api', gitlabPreparer); + preparers.register('azure/api', azurePreparer); + + const githubConfig = config.getOptionalConfig('scaffolder.github'); + if (githubConfig) { + try { + const githubToken = githubConfig.getString('token'); + const githubPreparer = new GithubPreparer({ token: githubToken }); + + preparers.register('github', githubPreparer); + } catch (e) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize github scaffolding provider, ${e.message}`, + ); + } + + logger.warn(`Skipping github scaffolding provider, ${e.message}`); + } + } + + return preparers; + } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts index c9dd14c0a3..4936461c5c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts @@ -17,6 +17,11 @@ import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; import { RemoteProtocol } from '../types'; +export type PreparerOptions = { + logger: Logger; + workingDirectory?: string; +}; + export type PreparerBase = { /** * Given an Entity definition from the Service Catalog, go and prepare a directory @@ -25,7 +30,7 @@ export type PreparerBase = { */ prepare( template: TemplateEntityV1alpha1, - opts: { logger: Logger }, + opts: PreparerOptions, ): Promise; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts index 8b84eb0edf..9ea7d2de5f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts @@ -17,10 +17,13 @@ jest.mock('nodegit'); jest.mock('azure-devops-node-api/GitApi'); jest.mock('azure-devops-node-api/interfaces/GitInterfaces'); +jest.mock('./helpers', () => ({ + pushToRemoteUserPass: jest.fn(), +})); import { AzurePublisher } from './azure'; import { GitApi } from 'azure-devops-node-api/GitApi'; -import * as NodeGit from 'nodegit'; +import { pushToRemoteUserPass } from './helpers'; const { mockGitApi } = require('azure-devops-node-api/GitApi') as { mockGitApi: { @@ -28,25 +31,6 @@ const { mockGitApi } = require('azure-devops-node-api/GitApi') as { }; }; -const { - Repository, - mockRepo, - mockIndex, - Signature, - Remote, - mockRemote, - Cred, -} = require('nodegit') as { - Repository: jest.Mocked<{ init: any }>; - Signature: jest.Mocked<{ now: any }>; - Cred: jest.Mocked<{ userpassPlaintextNew: any }>; - Remote: jest.Mocked<{ create: any }>; - - mockIndex: jest.Mocked; - mockRepo: jest.Mocked; - mockRemote: jest.Mocked; -}; - describe('Azure Publisher', () => { const publisher = new AzurePublisher(new GitApi('', []), 'fake-token'); @@ -57,10 +41,10 @@ describe('Azure Publisher', () => { describe('publish: createRemoteInAzure', () => { it('should use azure-devops-node-api to create a repo in the given project', async () => { mockGitApi.createRepository.mockResolvedValue({ - remoteUrl: 'mockclone', + remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', } as { remoteUrl: string }); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'project/repo', owner: 'bob', @@ -68,109 +52,20 @@ describe('Azure Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', + catalogInfoUrl: + 'https://dev.azure.com/organization/project/_git/repo?path=%2Fcatalog-info.yaml', + }); expect(mockGitApi.createRepository).toHaveBeenCalledWith( { name: 'repo', }, 'project', ); - }); - }); - - describe('publish: createGitDirectory', () => { - const values = { - isOrg: true, - storePath: 'blam/test', - owner: 'lols', - }; - - const mockDir = '/tmp/test/dir'; - - mockGitApi.createRepository.mockResolvedValue({ - remoteUrl: 'mockclone', - } as { remoteUrl: string }); - - it('should call init on the repo with the directory', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); - }); - - it('should call refresh index on the index and write the new files', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockRepo.refreshIndex).toHaveBeenCalled(); - }); - - it('should call add all files and write', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockIndex.addAll).toHaveBeenCalled(); - expect(mockIndex.write).toHaveBeenCalled(); - expect(mockIndex.writeTree).toHaveBeenCalled(); - }); - - it('should create a commit with on head with the right name and commiter', async () => { - const mockSignature = { mockSignature: 'bloblly' }; - Signature.now.mockReturnValue(mockSignature); - - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Signature.now).toHaveBeenCalledTimes(2); - expect(Signature.now).toHaveBeenCalledWith( - 'Scaffolder', - 'scaffolder@backstage.io', - ); - - expect(mockRepo.createCommit).toHaveBeenCalledWith( - 'HEAD', - mockSignature, - mockSignature, - 'initial commit', - 'mockoid', - [], - ); - }); - - it('creates a remote with the repo and remote', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Remote.create).toHaveBeenCalledWith( - mockRepo, - 'origin', - 'mockclone', - ); - }); - - it('shoud push to the remote repo', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - const [remotes, { callbacks }] = mockRemote.push.mock - .calls[0] as NodeGit.PushOptions[]; - - expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); - - callbacks?.credentials?.(); - - expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://dev.azure.com/organization/project/_git/repo', 'notempty', 'fake-token', ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts index 9dfc7b5b66..1e962bf223 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts @@ -14,13 +14,12 @@ * limitations under the License. */ -import { PublisherBase } from './types'; +import { PublisherBase, PublisherOptions, PublisherResult } from './types'; import { GitApi } from 'azure-devops-node-api/GitApi'; import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; - +import { pushToRemoteUserPass } from './helpers'; import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; -import { Repository, Remote, Signature, Cred } from 'nodegit'; export class AzurePublisher implements PublisherBase { private readonly client: GitApi; @@ -34,14 +33,12 @@ export class AzurePublisher implements PublisherBase { async publish({ values, directory, - }: { - values: RequiredTemplateValues & Record; - directory: string; - }): Promise<{ remoteUrl: string }> { + }: PublisherOptions): Promise { const remoteUrl = await this.createRemote(values); - await this.pushToRemote(directory, remoteUrl); + await pushToRemoteUserPass(directory, remoteUrl, 'notempty', this.token); + const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`; - return { remoteUrl }; + return { remoteUrl, catalogInfoUrl }; } private async createRemote( @@ -54,29 +51,4 @@ export class AzurePublisher implements PublisherBase { return repo.remoteUrl || ''; } - - private async pushToRemote(directory: string, remote: string): Promise { - const repo = await Repository.init(directory, 0); - const index = await repo.refreshIndex(); - await index.addAll(); - await index.write(); - const oid = await index.writeTree(); - await repo.createCommit( - 'HEAD', - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - 'initial commit', - oid, - [], - ); - - const remoteRepo = await Remote.create(repo, 'origin', remote); - - await remoteRepo.push(['refs/heads/master:refs/heads/master'], { - callbacks: { - // Username can anything but the empty string according to: https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat - credentials: () => Cred.userpassPlaintextNew('notempty', this.token), - }, - }); - } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts index e68b0d0447..cda64faf25 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -16,15 +16,18 @@ jest.mock('@octokit/rest'); jest.mock('nodegit'); +jest.mock('./helpers', () => ({ + pushToRemoteUserPass: jest.fn(), +})); import { Octokit } from '@octokit/rest'; -import * as NodeGit from 'nodegit'; import { OctokitResponse, ReposCreateInOrgResponseData, UsersGetByUsernameResponseData, } from '@octokit/types'; import { GithubPublisher } from './github'; +import { pushToRemoteUserPass } from './helpers'; const { mockGithubClient } = require('@octokit/rest') as { mockGithubClient: { @@ -34,25 +37,6 @@ const { mockGithubClient } = require('@octokit/rest') as { }; }; -const { - Repository, - mockRepo, - mockIndex, - Signature, - Remote, - mockRemote, - Cred, -} = require('nodegit') as { - Repository: jest.Mocked<{ init: any }>; - Signature: jest.Mocked<{ now: any }>; - Cred: jest.Mocked<{ userpassPlaintextNew: any }>; - Remote: jest.Mocked<{ create: any }>; - - mockIndex: jest.Mocked; - mockRepo: jest.Mocked; - mockRemote: jest.Mocked; -}; - describe('GitHub Publisher', () => { beforeEach(() => { jest.clearAllMocks(); @@ -69,11 +53,16 @@ describe('GitHub Publisher', () => { it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { mockGithubClient.repos.createInOrg.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { + type: 'Organization', + }, + } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -82,6 +71,11 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ org: 'blam', name: 'test', @@ -97,12 +91,18 @@ describe('GitHub Publisher', () => { repo: 'test', permission: 'admin', }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); mockGithubClient.users.getByUsername.mockResolvedValue({ @@ -111,7 +111,7 @@ describe('GitHub Publisher', () => { }, } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -120,6 +120,11 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect( mockGithubClient.repos.createForAuthenticatedUser, ).toHaveBeenCalledWith({ @@ -127,13 +132,19 @@ describe('GitHub Publisher', () => { private: false, }); expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); }); it('should invite other user in the authed user', async () => { mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); mockGithubClient.users.getByUsername.mockResolvedValue({ @@ -142,7 +153,7 @@ describe('GitHub Publisher', () => { }, } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -152,6 +163,11 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect( mockGithubClient.repos.createForAuthenticatedUser, ).toHaveBeenCalledWith({ @@ -165,113 +181,12 @@ describe('GitHub Publisher', () => { username: 'bob', permission: 'admin', }); - }); - - describe('publish: createGitDirectory', () => { - const values = { - storePath: 'blam/test', - owner: 'lols', - access: 'lols', - }; - - const mockDir = '/tmp/test/dir'; - - mockGithubClient.repos.createInOrg.mockResolvedValue({ - data: { - clone_url: 'mockclone', - }, - } as OctokitResponse); - mockGithubClient.users.getByUsername.mockResolvedValue({ - data: { - type: 'Organization', - }, - } as OctokitResponse); - - it('should call init on the repo with the directory', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); - }); - - it('should call refresh index on the index and write the new files', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockRepo.refreshIndex).toHaveBeenCalled(); - }); - - it('should call add all files and write', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockIndex.addAll).toHaveBeenCalled(); - expect(mockIndex.write).toHaveBeenCalled(); - expect(mockIndex.writeTree).toHaveBeenCalled(); - }); - - it('should create a commit with on head with the right name and commiter', async () => { - const mockSignature = { mockSignature: 'bloblly' }; - Signature.now.mockReturnValue(mockSignature); - - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Signature.now).toHaveBeenCalledTimes(2); - expect(Signature.now).toHaveBeenCalledWith( - 'Scaffolder', - 'scaffolder@backstage.io', - ); - - expect(mockRepo.createCommit).toHaveBeenCalledWith( - 'HEAD', - mockSignature, - mockSignature, - 'initial commit', - 'mockoid', - [], - ); - }); - - it('creates a remote with the repo and remote', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Remote.create).toHaveBeenCalledWith( - mockRepo, - 'origin', - 'mockclone', - ); - }); - - it('shoud push to the remote repo', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - const [remotes, { callbacks }] = mockRemote.push.mock - .calls[0] as NodeGit.PushOptions[]; - - expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); - - callbacks?.credentials?.(); - - expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( - 'abc', - 'x-oauth-basic', - ); - }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); }); @@ -285,7 +200,7 @@ describe('GitHub Publisher', () => { it('creates a private repository in the organization with visibility set to internal', async () => { mockGithubClient.repos.createInOrg.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); mockGithubClient.users.getByUsername.mockResolvedValue({ @@ -294,7 +209,7 @@ describe('GitHub Publisher', () => { }, } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { isOrg: true, storePath: 'blam/test', @@ -303,12 +218,23 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({ org: 'blam', name: 'test', private: true, visibility: 'internal', }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); }); @@ -322,7 +248,7 @@ describe('GitHub Publisher', () => { it('creates a private repository', async () => { mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { - clone_url: 'mockclone', + clone_url: 'https://github.com/backstage/backstage.git', }, } as OctokitResponse); mockGithubClient.users.getByUsername.mockResolvedValue({ @@ -331,7 +257,7 @@ describe('GitHub Publisher', () => { }, } as OctokitResponse); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -339,12 +265,23 @@ describe('GitHub Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ + remoteUrl: 'https://github.com/backstage/backstage.git', + catalogInfoUrl: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); expect( mockGithubClient.repos.createForAuthenticatedUser, ).toHaveBeenCalledWith({ name: 'test', private: true, }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'https://github.com/backstage/backstage.git', + 'abc', + 'x-oauth-basic', + ); }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 60ed86daa1..64976b8e41 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import { PublisherBase } from './types'; +import { PublisherBase, PublisherOptions, PublisherResult } from './types'; import { Octokit } from '@octokit/rest'; - +import { pushToRemoteUserPass } from './helpers'; import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; -import { Repository, Remote, Signature, Cred } from 'nodegit'; export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; @@ -47,14 +46,20 @@ export class GithubPublisher implements PublisherBase { async publish({ values, directory, - }: { - values: RequiredTemplateValues & Record; - directory: string; - }): Promise<{ remoteUrl: string }> { + }: PublisherOptions): Promise { const remoteUrl = await this.createRemote(values); - await this.pushToRemote(directory, remoteUrl); + await pushToRemoteUserPass( + directory, + remoteUrl, + this.token, + 'x-oauth-basic', + ); + const catalogInfoUrl = remoteUrl.replace( + /\.git$/, + '/blob/master/catalog-info.yaml', + ); - return { remoteUrl }; + return { remoteUrl, catalogInfoUrl }; } private async createRemote( @@ -104,29 +109,4 @@ export class GithubPublisher implements PublisherBase { return data?.clone_url; } - - private async pushToRemote(directory: string, remote: string): Promise { - const repo = await Repository.init(directory, 0); - const index = await repo.refreshIndex(); - await index.addAll(); - await index.write(); - const oid = await index.writeTree(); - await repo.createCommit( - 'HEAD', - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - 'initial commit', - oid, - [], - ); - - const remoteRepo = await Remote.create(repo, 'origin', remote); - await remoteRepo.push(['refs/heads/master:refs/heads/master'], { - callbacks: { - credentials: () => { - return Cred.userpassPlaintextNew(this.token, 'x-oauth-basic'); - }, - }, - }); - } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index 43b19859de..a87fa6c6d9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -16,11 +16,14 @@ jest.mock('nodegit'); jest.mock('@gitbeaker/node'); +jest.mock('./helpers', () => ({ + pushToRemoteUserPass: jest.fn(), +})); import { GitlabPublisher } from './gitlab'; import { Gitlab as GitlabAPI } from '@gitbeaker/core'; import { Gitlab } from '@gitbeaker/node'; -import * as NodeGit from 'nodegit'; +import { pushToRemoteUserPass } from './helpers'; const { mockGitlabClient } = require('@gitbeaker/node') as { mockGitlabClient: { @@ -30,25 +33,6 @@ const { mockGitlabClient } = require('@gitbeaker/node') as { }; }; -const { - Repository, - mockRepo, - mockIndex, - Signature, - Remote, - mockRemote, - Cred, -} = require('nodegit') as { - Repository: jest.Mocked<{ init: any }>; - Signature: jest.Mocked<{ now: any }>; - Cred: jest.Mocked<{ userpassPlaintextNew: any }>; - Remote: jest.Mocked<{ create: any }>; - - mockIndex: jest.Mocked; - mockRepo: jest.Mocked; - mockRemote: jest.Mocked; -}; - describe('GitLab Publisher', () => { const publisher = new GitlabPublisher(new Gitlab({}), 'fake-token'); @@ -61,8 +45,11 @@ describe('GitLab Publisher', () => { mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 42, } as { id: number }); + mockGitlabClient.Projects.create.mockResolvedValue({ + http_url_to_repo: 'mockclone', + } as { http_url_to_repo: string }); - await publisher.publish({ + const result = await publisher.publish({ values: { isOrg: true, storePath: 'blam/test', @@ -71,10 +58,17 @@ describe('GitLab Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ remoteUrl: 'mockclone' }); expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 42, name: 'test', }); + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', + 'mockclone', + 'oauth2', + 'fake-token', + ); }); it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { @@ -86,7 +80,7 @@ describe('GitLab Publisher', () => { http_url_to_repo: 'mockclone', } as { http_url_to_repo: string }); - await publisher.publish({ + const result = await publisher.publish({ values: { storePath: 'blam/test', owner: 'bob', @@ -94,109 +88,15 @@ describe('GitLab Publisher', () => { directory: '/tmp/test', }); + expect(result).toEqual({ remoteUrl: 'mockclone' }); expect(mockGitlabClient.Users.current).toHaveBeenCalled(); - expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 21, name: 'test', }); - }); - }); - - describe('publish: createGitDirectory', () => { - const values = { - isOrg: true, - storePath: 'blam/test', - owner: 'lols', - }; - - const mockDir = '/tmp/test/dir'; - - mockGitlabClient.Projects.create.mockResolvedValue({ - http_url_to_repo: 'mockclone', - } as { http_url_to_repo: string }); - - it('should call init on the repo with the directory', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Repository.init).toHaveBeenCalledWith(mockDir, 0); - }); - - it('should call refresh index on the index and write the new files', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockRepo.refreshIndex).toHaveBeenCalled(); - }); - - it('should call add all files and write', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(mockIndex.addAll).toHaveBeenCalled(); - expect(mockIndex.write).toHaveBeenCalled(); - expect(mockIndex.writeTree).toHaveBeenCalled(); - }); - - it('should create a commit with on head with the right name and commiter', async () => { - const mockSignature = { mockSignature: 'bloblly' }; - Signature.now.mockReturnValue(mockSignature); - - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Signature.now).toHaveBeenCalledTimes(2); - expect(Signature.now).toHaveBeenCalledWith( - 'Scaffolder', - 'scaffolder@backstage.io', - ); - - expect(mockRepo.createCommit).toHaveBeenCalledWith( - 'HEAD', - mockSignature, - mockSignature, - 'initial commit', - 'mockoid', - [], - ); - }); - - it('creates a remote with the repo and remote', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - expect(Remote.create).toHaveBeenCalledWith( - mockRepo, - 'origin', + expect(pushToRemoteUserPass).toHaveBeenCalledWith( + '/tmp/test', 'mockclone', - ); - }); - - it('shoud push to the remote repo', async () => { - await publisher.publish({ - values, - directory: mockDir, - }); - - const [remotes, { callbacks }] = mockRemote.push.mock - .calls[0] as NodeGit.PushOptions[]; - - expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); - - callbacks?.credentials?.(); - - expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( 'oauth2', 'fake-token', ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts index a755a138b5..4fae8e8a5c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import { PublisherBase } from './types'; +import { PublisherBase, PublisherOptions, PublisherResult } from './types'; import { Gitlab } from '@gitbeaker/core'; - +import { pushToRemoteUserPass } from './helpers'; import { JsonValue } from '@backstage/config'; import { RequiredTemplateValues } from '../templater'; -import { Repository, Remote, Signature, Cred } from 'nodegit'; export class GitlabPublisher implements PublisherBase { private readonly client: Gitlab; @@ -33,12 +32,9 @@ export class GitlabPublisher implements PublisherBase { async publish({ values, directory, - }: { - values: RequiredTemplateValues & Record; - directory: string; - }): Promise<{ remoteUrl: string }> { + }: PublisherOptions): Promise { const remoteUrl = await this.createRemote(values); - await this.pushToRemote(directory, remoteUrl); + await pushToRemoteUserPass(directory, remoteUrl, 'oauth2', this.token); return { remoteUrl }; } @@ -63,28 +59,4 @@ export class GitlabPublisher implements PublisherBase { return project?.http_url_to_repo; } - - private async pushToRemote(directory: string, remote: string): Promise { - const repo = await Repository.init(directory, 0); - const index = await repo.refreshIndex(); - await index.addAll(); - await index.write(); - const oid = await index.writeTree(); - await repo.createCommit( - 'HEAD', - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - Signature.now('Scaffolder', 'scaffolder@backstage.io'), - 'initial commit', - oid, - [], - ); - - const remoteRepo = await Remote.create(repo, 'origin', remote); - - await remoteRepo.push(['refs/heads/master:refs/heads/master'], { - callbacks: { - credentials: () => Cred.userpassPlaintextNew('oauth2', this.token), - }, - }); - } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.test.ts new file mode 100644 index 0000000000..3a317adacd --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.test.ts @@ -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. + */ +jest.mock('nodegit'); +import * as NodeGit from 'nodegit'; +import { pushToRemoteCred } from './helpers'; + +const { + Repository, + mockRepo, + mockIndex, + Signature, + Remote, + mockRemote, + Cred, +} = require('nodegit') as { + Repository: jest.Mocked<{ init: any }>; + Signature: jest.Mocked<{ now: any }>; + Cred: jest.Mocked<{ userpassPlaintextNew: any }>; + Remote: jest.Mocked<{ create: any }>; + + mockIndex: jest.Mocked; + mockRepo: jest.Mocked; + mockRemote: jest.Mocked; +}; + +describe('pushToRemoteCred', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const directory = '/tmp/test/dir'; + const remote = 'mockclone'; + const credentialsProvider = () => + NodeGit.Cred.userpassPlaintextNew('username', 'password'); + + it('should call init on the repo with the directory', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(Repository.init).toHaveBeenCalledWith(directory, 0); + }); + + it('should call refresh index on the index and write the new files', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(mockRepo.refreshIndex).toHaveBeenCalled(); + }); + + it('should call add all files and write', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(mockIndex.addAll).toHaveBeenCalled(); + expect(mockIndex.write).toHaveBeenCalled(); + expect(mockIndex.writeTree).toHaveBeenCalled(); + }); + + it('should create a commit with on head with the right name and commiter', async () => { + const mockSignature = { mockSignature: 'bloblly' }; + Signature.now.mockReturnValue(mockSignature); + + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(Signature.now).toHaveBeenCalledTimes(2); + expect(Signature.now).toHaveBeenCalledWith( + 'Scaffolder', + 'scaffolder@backstage.io', + ); + + expect(mockRepo.createCommit).toHaveBeenCalledWith( + 'HEAD', + mockSignature, + mockSignature, + 'initial commit', + 'mockoid', + [], + ); + }); + + it('creates a remote with the repo and remote', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + expect(Remote.create).toHaveBeenCalledWith(mockRepo, 'origin', 'mockclone'); + }); + + it('shoud push to the remote repo', async () => { + await pushToRemoteCred(directory, remote, credentialsProvider); + + const [remotes, { callbacks }] = mockRemote.push.mock + .calls[0] as NodeGit.PushOptions[]; + + expect(remotes).toEqual(['refs/heads/master:refs/heads/master']); + + callbacks?.credentials?.(); + + expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith( + 'username', + 'password', + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts new file mode 100644 index 0000000000..51f5a4c854 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts @@ -0,0 +1,55 @@ +/* + * 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 { Repository, Remote, Signature, Cred } from 'nodegit'; + +export async function pushToRemoteCred( + directory: string, + remote: string, + credentialsProvider: () => Cred, +): Promise { + const repo = await Repository.init(directory, 0); + const index = await repo.refreshIndex(); + await index.addAll(); + await index.write(); + const oid = await index.writeTree(); + await repo.createCommit( + 'HEAD', + Signature.now('Scaffolder', 'scaffolder@backstage.io'), + Signature.now('Scaffolder', 'scaffolder@backstage.io'), + 'initial commit', + oid, + [], + ); + + const remoteRepo = await Remote.create(repo, 'origin', remote); + + await remoteRepo.push(['refs/heads/master:refs/heads/master'], { + callbacks: { + credentials: credentialsProvider, + }, + }); +} + +export async function pushToRemoteUserPass( + directory: string, + remote: string, + username: string, + password: string, +): Promise { + return pushToRemoteCred(directory, remote, () => + Cred.userpassPlaintextNew(username, password), + ); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index a88acc518d..db383aaf7f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -14,26 +14,145 @@ * limitations under the License. */ +import { Logger } from 'winston'; +import { Octokit } from '@octokit/rest'; +import { Gitlab } from '@gitbeaker/node'; +import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; +import { Config } from '@backstage/config'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; +import { + DeprecatedLocationTypeDetector, + makeDeprecatedLocationTypeDetector, + parseLocationAnnotation, +} from '../helpers'; import { PublisherBase, PublisherBuilder } from './types'; import { RemoteProtocol } from '../types'; +import { GithubPublisher, RepoVisibilityOptions } from './github'; +import { GitlabPublisher } from './gitlab'; +import { AzurePublisher } from './azure'; export class Publishers implements PublisherBuilder { private publisherMap = new Map(); + constructor(private readonly typeDetector?: DeprecatedLocationTypeDetector) {} + register(protocol: RemoteProtocol, publisher: PublisherBase) { this.publisherMap.set(protocol, publisher); } get(template: TemplateEntityV1alpha1): PublisherBase { - const { protocol } = parseLocationAnnotation(template); + const { protocol, location } = parseLocationAnnotation(template); const publisher = this.publisherMap.get(protocol); if (!publisher) { + if ((protocol as string) === 'url') { + const type = this.typeDetector?.(location); + const detected = type && this.publisherMap.get(type as RemoteProtocol); + if (detected) { + return detected; + } + throw new Error(`No preparer integration found for url "${location}"`); + } throw new Error(`No publisher registered for type: "${protocol}"`); } return publisher; } + + static async fromConfig( + config: Config, + { logger }: { logger: Logger }, + ): Promise { + const typeDetector = makeDeprecatedLocationTypeDetector(config); + const publishers = new Publishers(typeDetector); + + const githubConfig = config.getOptionalConfig('scaffolder.github'); + if (githubConfig) { + try { + const repoVisibility = githubConfig.getString( + 'visibility', + ) as RepoVisibilityOptions; + + const githubToken = githubConfig.getString('token'); + const githubHost = + githubConfig.getOptionalString('host') ?? 'https://api.github.com'; + const githubClient = new Octokit({ + auth: githubToken, + baseUrl: githubHost, + }); + const githubPublisher = new GithubPublisher({ + client: githubClient, + token: githubToken, + repoVisibility, + }); + + publishers.register('file', githubPublisher); + publishers.register('github', githubPublisher); + } catch (e) { + const providerName = 'github'; + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, + ); + } + + logger.warn( + `Skipping ${providerName} scaffolding provider, ${e.message}`, + ); + } + } + + const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api'); + if (gitLabConfig) { + try { + const gitLabToken = gitLabConfig.getString('token'); + const gitLabClient = new Gitlab({ + host: gitLabConfig.getOptionalString('baseUrl'), + token: gitLabToken, + }); + const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); + publishers.register('gitlab', gitLabPublisher); + publishers.register('gitlab/api', gitLabPublisher); + } catch (e) { + const providerName = 'gitlab'; + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, + ); + } + + logger.warn( + `Skipping ${providerName} scaffolding provider, ${e.message}`, + ); + } + } + + const azureConfig = config.getOptionalConfig('scaffolder.azure'); + if (azureConfig) { + try { + const baseUrl = azureConfig.getString('baseUrl'); + const azureToken = azureConfig.getConfig('api').getString('token'); + + const authHandler = getPersonalAccessTokenHandler(azureToken); + const webApi = new WebApi(baseUrl, authHandler); + const azureClient = await webApi.getGitApi(); + + const azurePublisher = new AzurePublisher(azureClient, azureToken); + publishers.register('azure/api', azurePublisher); + } catch (e) { + const providerName = 'azure'; + if (process.env.NODE_ENV !== 'development') { + throw new Error( + `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, + ); + } + + logger.warn( + `Skipping ${providerName} scaffolding provider, ${e.message}`, + ); + } + } + + return publishers; + } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index 6e9c45ba43..f3bc59e19d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts @@ -29,11 +29,17 @@ export type PublisherBase = { * catalog, plus the values from the form and the directory that has * been templated */ - publish(opts: { - entity: TemplateEntityV1alpha1; - values: RequiredTemplateValues & Record; - directory: string; - }): Promise<{ remoteUrl: string }>; + publish(opts: PublisherOptions): Promise; +}; + +export type PublisherOptions = { + values: RequiredTemplateValues & Record; + directory: string; +}; + +export type PublisherResult = { + remoteUrl: string; + catalogInfoUrl?: string; }; export type PublisherBuilder = { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index 31bcdf189b..568c95a073 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -13,16 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('./helpers', () => ({ runDockerContainer: jest.fn() })); +jest.mock('./helpers', () => ({ + runDockerContainer: jest.fn(), + runCommand: jest.fn(), +})); +jest.mock('command-exists-promise', () => jest.fn()); import { CookieCutter } from './cookiecutter'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; -import { RunDockerContainerOptions } from './helpers'; +import { RunDockerContainerOptions, RunCommandOptions } from './helpers'; import { PassThrough } from 'stream'; import Docker from 'dockerode'; +const commandExists = require('command-exists-promise'); + describe('CookieCutter Templater', () => { const cookie = new CookieCutter(); const mockDocker = {} as Docker; @@ -32,6 +38,10 @@ describe('CookieCutter Templater', () => { runDockerContainer: jest.Mock; } = require('./helpers'); + jest + .spyOn(fs, 'readdir') + .mockImplementation(() => Promise.resolve(['newthing'])); + beforeEach(async () => { jest.clearAllMocks(); }); @@ -41,12 +51,12 @@ describe('CookieCutter Templater', () => { return await fs.promises.mkdtemp(path.join(tempDir, 'temp')); }; - it('should write a cookiecutter.json file with the values from the entitiy', async () => { + it('should write a cookiecutter.json file with the values from the entity', async () => { const tempdir = await mkTemp(); const values = { owner: 'blobby', - storePath: 'spotify/end-repo', + storePath: 'backstage/end-repo', description: 'description', component_id: 'newthing', }; @@ -68,7 +78,7 @@ describe('CookieCutter Templater', () => { const values = { owner: 'blobby', - storePath: 'spotify/end-repo', + storePath: 'backstage/end-repo', component_id: 'something', }; @@ -86,7 +96,7 @@ describe('CookieCutter Templater', () => { const values = { owner: 'blobby', - storePath: 'spotify/end-repo', + storePath: 'backstage/end-repo', }; await expect( @@ -99,7 +109,7 @@ describe('CookieCutter Templater', () => { const values = { owner: 'blobby', - storePath: 'spotify/end-repo', + storePath: 'backstage/end-repo', component_id: 'newthing', }; @@ -127,7 +137,7 @@ describe('CookieCutter Templater', () => { const values = { owner: 'blobby', - storePath: 'spotify/end-repo', + storePath: 'backstage/end-repo', component_id: 'newthing', }; @@ -147,7 +157,7 @@ describe('CookieCutter Templater', () => { const values = { owner: 'blobby', - storePath: 'spotify/end-repo', + storePath: 'backstage/end-repo', component_id: 'newthing', }; @@ -174,4 +184,71 @@ describe('CookieCutter Templater', () => { dockerClient: mockDocker, }); }); + + describe('when cookiecutter is available', () => { + beforeAll(() => { + commandExists.mockImplementation(() => () => true); + }); + + it('use the binary', async () => { + const { + runCommand, + }: { + runCommand: jest.Mock; + } = require('./helpers'); + + const stream = new PassThrough(); + + const tempdir = await mkTemp(); + + const values = { + owner: 'blobby', + storePath: 'backstage/end-repo', + component_id: 'newthing', + }; + + await cookie.run({ + directory: tempdir, + values, + logStream: stream, + dockerClient: mockDocker, + }); + + expect(runCommand).toHaveBeenCalledWith({ + command: 'cookiecutter', + args: expect.arrayContaining([ + '--no-input', + '-o', + tempdir, + expect.stringContaining(`${tempdir}-result`), + '--verbose', + ]), + logStream: stream, + }); + }); + }); + + describe('when nothing was generated', () => { + beforeEach(() => { + jest.spyOn(fs, 'readdir').mockImplementation(() => Promise.resolve([])); + }); + + it('throws an error', async () => { + const stream = new PassThrough(); + + const tempdir = await mkTemp(); + + return expect( + cookie.run({ + directory: tempdir, + values: { + owner: 'blobby', + storePath: 'backstage/end-repo', + }, + logStream: stream, + dockerClient: mockDocker, + }), + ).rejects.toThrow(/Cookie Cutter did not generate anything/); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index 21b6fc3dde..7be8730bd9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -78,8 +78,16 @@ export class CookieCutter implements TemplaterBase { }); } + // if cookiecutter was successful, resultDir will contain + // exactly one directory. + const [generated] = await fs.readdir(resultDir); + + if (generated === undefined) { + throw new Error('Cookie Cutter did not generate anything'); + } + return { - resultDir: path.resolve(resultDir, options.values.component_id as string), + resultDir: path.resolve(resultDir, generated), }; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts index a4441ca23f..57e47f4c27 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts @@ -17,16 +17,9 @@ import Stream, { PassThrough } from 'stream'; import os from 'os'; import fs from 'fs'; import Docker from 'dockerode'; -import { runDockerContainer } from './helpers'; +import { UserOptions, runDockerContainer } from './helpers'; describe('helpers', () => { - if (process.platform === 'win32') { - // eslint-disable-next-line jest/no-focused-tests - it.only('should skip tests on windows', () => { - expect('test').not.toBe('run'); - }); - } - const mockDocker = new Docker() as jest.Mocked; beforeEach(() => { @@ -142,12 +135,17 @@ describe('helpers', () => { dockerClient: mockDocker, }); + const userOptions: UserOptions = {}; + if (process.getuid && process.getgid) { + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + expect(mockDocker.run).toHaveBeenCalledWith( imageName, args, expect.any(Stream), expect.objectContaining({ - User: `${process.getuid()}:${process.getgid()}`, + ...userOptions, Env: ['HOME=/tmp'], }), ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts index e71b63bfb0..e79f2cb066 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts @@ -36,6 +36,10 @@ export type RunCommandOptions = { logStream?: Writable; }; +export type UserOptions = { + User?: string; +}; + /** * Gets the templater key to use for templating from the entity * @param entity Template entity @@ -115,6 +119,16 @@ export const runDockerContainer = async ({ }); }); + const userOptions: UserOptions = {}; + if (process.getuid && process.getgid) { + // Files that are created inside the Docker container will be owned by + // root on the host system on non Mac systems, because of reasons. Mainly the fact that + // volume sharing is done using NFS on Mac and actual mounts in Linux world. + // So we set the user in the container as the same user and group id as the host. + // On Windows we don't have process.getuid nor process.getgid + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( imageName, args, @@ -129,11 +143,7 @@ export const runDockerContainer = async ({ `${await fs.promises.realpath(templateDir)}:/template`, ], }, - // Files that are created inside the Docker container will be owned by - // root on the host system on non Mac systems, because of reasons. Mainly the fact that - // volume sharing is done using NFS on Mac and actual mounts in Linux world. - // So we set the user in the container as the same user and group id as the host. - User: `${process.getuid()}:${process.getgid()}`, + ...userOptions, // Set the home directory inside the container as something that applications can // write to, otherwise they will just flop and fail trying to write to / Env: ['HOME=/tmp'], diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts index 1c4fe90207..9ef20b7740 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts @@ -24,7 +24,7 @@ describe('Templaters', () => { metadata: { annotations: { 'backstage.io/managed-by-location': - 'file:/Users/bingo/spotify/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml', + 'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml', }, name: 'react-ssr-template', title: 'React SSR Template', diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 35d6b50ece..c2b60a2040 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -14,7 +14,19 @@ * limitations under the License. */ +const mockAccess = jest.fn(); +jest.doMock('fs-extra', () => ({ + promises: { + access: mockAccess, + }, + constants: { + F_OK: 0, + W_OK: 1, + }, +})); + import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; @@ -23,8 +35,154 @@ import Docker from 'dockerode'; jest.mock('dockerode'); +const generateEntityClient: any = (template: any) => ({ + findTemplate: () => Promise.resolve(template), +}); + +describe('createRouter - working directory', () => { + const mockPrepare = jest.fn(); + const mockPreparers = new Preparers(); + + beforeAll(() => { + const mockPreparer = { + prepare: mockPrepare, + }; + mockPreparers.register('azure/api', mockPreparer); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const workDirConfig = (path: string) => ({ + context: '', + data: { + backend: { + workingDirectory: path, + }, + }, + }); + + const template = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + annotations: { + 'backstage.io/managed-by-location': 'azure/api:dev.azure.com', + }, + }, + spec: { + owner: 'template@backstage.io', + path: '.', + schema: {}, + }, + }; + + const mockedEntityClient = generateEntityClient(template); + + it('should throw an error when working directory does not exist or is not writable', async () => { + mockAccess.mockImplementation(() => { + throw new Error('access error'); + }); + + await expect( + createRouter({ + logger: getVoidLogger(), + preparers: new Preparers(), + templaters: new Templaters(), + publishers: new Publishers(), + config: ConfigReader.fromConfigs([workDirConfig('/path')]), + dockerClient: new Docker(), + entityClient: mockedEntityClient, + }), + ).rejects.toThrow('access error'); + }); + + it('should use the working directory when configured', async () => { + const router = await createRouter({ + logger: getVoidLogger(), + preparers: mockPreparers, + templaters: new Templaters(), + publishers: new Publishers(), + config: ConfigReader.fromConfigs([workDirConfig('/path')]), + dockerClient: new Docker(), + entityClient: mockedEntityClient, + }); + + const app = express().use(router); + await request(app).post('/v1/jobs').send({ + templateName: '', + values: {}, + }); + + expect(mockPrepare).toBeCalledWith(expect.anything(), { + logger: expect.anything(), + workingDirectory: '/path', + }); + }); + + it('should not pass along anything when no working directory is configured', async () => { + const router = await createRouter({ + logger: getVoidLogger(), + preparers: mockPreparers, + templaters: new Templaters(), + publishers: new Publishers(), + config: ConfigReader.fromConfigs([]), + dockerClient: new Docker(), + entityClient: mockedEntityClient, + }); + + const app = express().use(router); + await request(app).post('/v1/jobs').send({ + templateName: '', + values: {}, + }); + + expect(mockPrepare).toBeCalledWith(expect.anything(), { + logger: expect.anything(), + }); + }); +}); + describe('createRouter', () => { let app: express.Express; + const template = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + }, + spec: { + owner: 'web@example.com', + path: '.', + schema: { + properties: { + component_id: { + description: 'Unique name of the component', + title: 'Name', + type: 'string', + }, + description: { + description: 'Description of the component', + title: 'Description', + type: 'string', + }, + use_typescript: { + default: true, + description: 'Include typescript', + title: 'Use Typescript', + type: 'boolean', + }, + }, + required: ['component_id', 'use_typescript'], + }, + templater: 'cra', + type: 'website', + }, + }; beforeAll(async () => { const router = await createRouter({ @@ -32,7 +190,9 @@ describe('createRouter', () => { preparers: new Preparers(), templaters: new Templaters(), publishers: new Publishers(), + config: ConfigReader.fromConfigs([]), dockerClient: new Docker(), + entityClient: generateEntityClient(template), }); app = express().use(router); }); @@ -42,47 +202,9 @@ describe('createRouter', () => { }); describe('POST /v1/jobs', () => { - const template = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - description: 'Create a new CRA website project', - name: 'create-react-app-template', - tags: ['experimental', 'react', 'cra'], - title: 'Create React App Template', - }, - spec: { - owner: 'web@example.com', - path: '.', - schema: { - properties: { - component_id: { - description: 'Unique name of the component', - title: 'Name', - type: 'string', - }, - description: { - description: 'Description of the component', - title: 'Description', - type: 'string', - }, - use_typescript: { - default: true, - description: 'Include typescript', - title: 'Use Typescript', - type: 'boolean', - }, - }, - required: ['component_id', 'use_typescript'], - }, - templater: 'cra', - type: 'website', - }, - }; - it('rejects template values which do not match the template schema definition', async () => { const response = await request(app).post('/v1/jobs').send({ - template, + templateName: '', values: {}, }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index f61d2566b9..ac3b1c80ec 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { JsonValue } from '@backstage/config'; +import { Config, JsonValue } from '@backstage/config'; +import fs from 'fs-extra'; import Docker from 'dockerode'; import express from 'express'; import Router from 'express-promise-router'; @@ -28,6 +28,7 @@ import { TemplaterBuilder, PublisherBuilder, } from '../scaffolder'; +import { CatalogEntityClient } from '../lib/catalog'; import { validate, ValidatorResult } from 'jsonschema'; export interface RouterOptions { @@ -36,7 +37,9 @@ export interface RouterOptions { publishers: PublisherBuilder; logger: Logger; + config: Config; dockerClient: Docker; + entityClient: CatalogEntityClient; } export async function createRouter( @@ -50,12 +53,34 @@ export async function createRouter( templaters, publishers, logger: parentLogger, + config, dockerClient, + entityClient, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); const jobProcessor = new JobProcessor(); + let workingDirectory: string; + if (config.has('backend.workingDirectory')) { + workingDirectory = config.getString('backend.workingDirectory'); + try { + // Check if working directory exists and is writable + await fs.promises.access( + workingDirectory, + fs.constants.F_OK | fs.constants.W_OK, + ); + logger.info(`using working directory: ${workingDirectory}`); + } catch (err) { + logger.error( + `working directory ${workingDirectory} ${ + err.code === 'ENOENT' ? 'does not exist' : 'is not writable' + }`, + ); + throw err; + } + } + router .get('/v1/job/:jobId', ({ params }, res) => { const job = jobProcessor.get(params.jobId); @@ -81,14 +106,17 @@ export async function createRouter( }); }) .post('/v1/jobs', async (req, res) => { - const template: TemplateEntityV1alpha1 = req.body.template; + const templateName: string = req.body.templateName; const values: RequiredTemplateValues & Record = req.body.values; + const template = await entityClient.findTemplate(templateName); + const validationResult: ValidatorResult = validate( values, template.spec.schema, ); + if (!validationResult.valid) { res.status(400).json({ errors: validationResult.errors }); return; @@ -104,6 +132,7 @@ export async function createRouter( const preparer = preparers.get(ctx.entity); const skeletonDir = await preparer.prepare(ctx.entity, { logger: ctx.logger, + workingDirectory, }); return { skeletonDir }; }, @@ -127,12 +156,11 @@ export async function createRouter( handler: async (ctx: StageContext<{ resultDir: string }>) => { const publisher = publishers.get(ctx.entity); ctx.logger.info('Will now store the template'); - const { remoteUrl } = await publisher.publish({ - entity: ctx.entity, + const result = await publisher.publish({ values: ctx.values, directory: ctx.resultDir, }); - return { remoteUrl }; + return result; }, }, ], diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md new file mode 100644 index 0000000000..9eac595997 --- /dev/null +++ b/plugins/scaffolder/CHANGELOG.md @@ -0,0 +1,98 @@ +# @backstage/plugin-scaffolder + +## 0.3.0 + +### Minor Changes + +- 59166e5ec: `createRouter` of scaffolder backend will now require additional option as `entityClient` which could be generated by `CatalogEntityClient` in `plugin-scaffolder-backend` package. Here is example to generate `entityClient`. + + ```js + import { CatalogEntityClient } from '@backstage/plugin-scaffolder-backend'; + import { SingleHostDiscovery } from '@backstage/backend-common'; + + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + ``` + + - Scaffolder's API `/v1/jobs` will accept `templateName` instead of `template` Entity. + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI + +### Patch Changes + +- fb74f1db6: Make title meaningful after component creation + + Fixes #2458. + + After the change, the UX should look like this: + + ### If the component creation was successful: + + ![successfully-created-component](https://user-images.githubusercontent.com/33940798/94339294-8bd1e000-0016-11eb-885b-7936fcc23b63.gif) + + ### If the component creation failed: + + ![failed-to-create-component](https://user-images.githubusercontent.com/33940798/94339296-90969400-0016-11eb-9a74-ce16b3dd8d88.gif) + +- c5ef12926: fix the accordion details design when job stage fail +- 1c8c43756: The new `scaffolder.github.baseUrl` config property allows to specify a custom base url for GitHub enterprise instances +- Updated dependencies [28edd7d29] +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [368fd8243] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [f0aa01bcc] +- Updated dependencies [0aecfded0] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [8b9c8196f] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [60d40892c] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [2ebcfac8d] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [0b956f21b] +- Updated dependencies [97c2cb19b] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/plugin-catalog@0.2.0 + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/scaffolder/README.md b/plugins/scaffolder/README.md index a356cb8ec9..05a68dafdd 100644 --- a/plugins/scaffolder/README.md +++ b/plugins/scaffolder/README.md @@ -6,5 +6,5 @@ This is the frontend part of the default scaffolder plugin. ## Links -- [Backend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/scaffolder-backend) +- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend) - [The Backstage homepage](https://backstage.io) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 1e59019ad7..439fc0ad9a 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.1.1-alpha.24", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,15 +21,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/plugin-catalog": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/core": "^0.3.1", + "@backstage/plugin-catalog": "^0.2.2", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@rjsf/core": "^2.1.0", - "@rjsf/material-ui": "^2.1.0", + "@rjsf/core": "^2.4.0", + "@rjsf/material-ui": "^2.4.0", "classnames": "^2.2.6", "moment": "^2.26.0", "react": "^16.13.1", @@ -41,17 +41,16 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", - "@backstage/test-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index f58c8e68d1..ca52418e92 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -15,7 +15,6 @@ */ import { createApiRef, DiscoveryApi } from '@backstage/core'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', @@ -33,20 +32,17 @@ export class ScaffolderApi { * Executes the scaffolding of a component, given a template and its * parameter values. * - * @param template Template entity for the scaffolder to use. New project is going to be created out of this template. + * @param templateName Template name for the scaffolder to use. New project is going to be created out of this template. * @param values Parameters for the template, e.g. name, description */ - async scaffold( - template: TemplateEntityV1alpha1, - values: Record, - ) { + async scaffold(templateName: string, values: Record) { const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ template, values: { ...values } }), + body: JSON.stringify({ templateName, values: { ...values } }), }); if (response.status !== 201) { diff --git a/plugins/scaffolder/src/components/JobStage/JobStage.tsx b/plugins/scaffolder/src/components/JobStage/JobStage.tsx index 0baba0c9b9..8b3fc04de0 100644 --- a/plugins/scaffolder/src/components/JobStage/JobStage.tsx +++ b/plugins/scaffolder/src/components/JobStage/JobStage.tsx @@ -73,6 +73,18 @@ const useStyles = makeStyles(theme => ({ boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`, }, }, + jobStatusTitle: { + display: 'flex', + width: '100%', + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + [theme.breakpoints.down('xs')]: { + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'flex-start', + }, + }, })); type Props = { @@ -118,14 +130,16 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { className: classes.button, }} > - + {name} {timeElapsed && `(${timeElapsed})`}{' '} {startedAt && !endedAt && } {log.length === 0 ? ( - No logs available for this step + + No logs available for this step + ) : ( }>
diff --git a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx index 149f7a47d7..d8b194eb41 100644 --- a/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx +++ b/plugins/scaffolder/src/components/JobStatusModal/JobStatusModal.tsx @@ -13,21 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useEffect } from 'react'; -import { - Dialog, - LinearProgress, - DialogTitle, - DialogContent, - DialogActions, -} from '@material-ui/core'; -import { JobStage } from '../JobStage/JobStage'; -import { useJobPolling } from './useJobPolling'; -import { Job } from '../../types'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Button } from '@backstage/core'; -import { entityRoute } from '@backstage/plugin-catalog'; +import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, +} from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; import { generatePath } from 'react-router-dom'; +import { Job } from '../../types'; +import { JobStage } from '../JobStage/JobStage'; +import { useJobPolling } from './useJobPolling'; type Props = { onComplete: (job: Job) => void; @@ -69,15 +69,10 @@ export const JobStatusModal = ({ jobId, onComplete, entity }: Props) => { {entity && ( diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index b5bdaaef4a..abdb411ad9 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -22,7 +22,6 @@ import { Header, Lifecycle, Page, - pageTheme, Progress, SupportButton, useApi, @@ -54,10 +53,12 @@ export const ScaffolderPage = () => { const { data: templates, isValidating, error } = useStaleWhileRevalidate( 'templates/all', - async () => - catalogApi.getEntities({ kind: 'Template' }) as Promise< - TemplateEntityV1alpha1[] - >, + async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'Template' }, + }); + return response.items as TemplateEntityV1alpha1[]; + }, ); useEffect(() => { @@ -66,7 +67,7 @@ export const ScaffolderPage = () => { }, [error, errorApi]); return ( - +
({ padding: theme.spacing(2, 2, 6), backgroundImage: (props: { backgroundImage: string }) => props.backgroundImage, + backgroundPosition: 0, }, content: { padding: theme.spacing(2), @@ -55,7 +63,10 @@ export const TemplateCard = ({ type, name, }: TemplateCardProps) => { - const theme = pageTheme[type] ?? pageTheme.other; + const backstageTheme = useTheme(); + + const themeId = pageTheme[type] ? type : 'other'; + const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); const href = generatePath(templateRoute.path, { templateName: name }); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 6229a88fc3..62963b58b8 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -13,18 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { TemplatePage } from './TemplatePage'; -import { wrapInTestApp, renderWithEffects } from '@backstage/test-utils'; -import { ApiRegistry, errorApiRef, ApiProvider } from '@backstage/core'; -import { scaffolderApiRef, ScaffolderApi } from '../../api'; -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; -import { mutate } from 'swr'; -import { act } from 'react-dom/test-utils'; -import { Route, MemoryRouter } from 'react-router'; -import { rootRoute } from '../../routes'; -import { ThemeProvider } from '@material-ui/core'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter, Route } from 'react-router'; +import { ScaffolderApi, scaffolderApiRef } from '../../api'; +import { rootRoute } from '../../routes'; +import { TemplatePage } from './TemplatePage'; const templateMock = { apiVersion: 'backstage.io/v1alpha1', @@ -90,48 +89,43 @@ const apis = ApiRegistry.from([ ]); describe('TemplatePage', () => { - afterEach(async () => { - // Cleaning up swr's cache - await act(async () => { - await mutate('templates/test'); - }); - }); + beforeEach(() => jest.resetAllMocks()); + it('renders correctly', async () => { - catalogApiMock.getEntities.mockResolvedValueOnce([templateMock]); - const rendered = await renderWithEffects( - wrapInTestApp( - - - , - ), + catalogApiMock.getEntities.mockResolvedValueOnce({ items: [templateMock] }); + const rendered = await renderInTestApp( + + + , ); expect(rendered.queryByText('Create a new component')).toBeInTheDocument(); expect(rendered.queryByText('React SSR Template')).toBeInTheDocument(); // await act(async () => await mutate('templates/test')); }); + it('renders spinner while loading', async () => { let resolve: Function; const promise = new Promise(res => { resolve = res; }); - catalogApiMock.getEntities.mockResolvedValueOnce(promise); - const rendered = await renderWithEffects( - wrapInTestApp( - - - , - ), + catalogApiMock.getEntities.mockReturnValueOnce(promise); + const rendered = await renderInTestApp( + + + , ); expect(rendered.queryByText('Create a new component')).toBeInTheDocument(); expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument(); // Need to cleanup the promise or will timeout - resolve!(); + act(() => { + resolve!({ items: [] }); + }); }); it('navigates away if no template was loaded', async () => { - catalogApiMock.getEntities.mockResolvedValueOnce([]); + catalogApiMock.getEntities.mockResolvedValueOnce({ items: [] }); const rendered = await renderWithEffects( diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 19196d39ad..ebefc6522d 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -22,34 +22,31 @@ import { Lifecycle, Page, useApi, - pageTheme, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; import React, { useState } from 'react'; -import { useParams } from 'react-router-dom'; -import useStaleWhileRevalidate from 'swr'; -import { scaffolderApiRef } from '../../api'; -import { JobStatusModal } from '../JobStatusModal'; -import { Job } from '../../types'; -import { MultistepJsonForm } from '../MultistepJsonForm'; import { Navigate } from 'react-router'; +import { useParams } from 'react-router-dom'; +import { useAsync } from 'react-use'; +import { scaffolderApiRef } from '../../api'; import { rootRoute } from '../../routes'; +import { Job } from '../../types'; +import { JobStatusModal } from '../JobStatusModal'; +import { MultistepJsonForm } from '../MultistepJsonForm'; const useTemplate = ( templateName: string, catalogApi: typeof catalogApiRef.T, ) => { - const { data, error } = useStaleWhileRevalidate( - `templates/${templateName}`, - async () => - catalogApi.getEntities({ - kind: 'Template', - 'metadata.name': templateName, - }) as Promise, - ); - return { template: data?.[0], loading: !error && !data, error }; + const { value, loading, error } = useAsync(async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'Template', 'metadata.name': templateName }, + }); + return response.items as TemplateEntityV1alpha1[]; + }); + return { template: value?.[0], loading, error }; }; const OWNER_REPO_SCHEMA = { @@ -96,7 +93,7 @@ export const TemplatePage = () => { const handleCreate = async () => { try { - const job = await scaffolderApi.scaffold(template!, formState); + const job = await scaffolderApi.scaffold(templateName, formState); setJobId(job); } catch (e) { errorApi.post(e); @@ -108,15 +105,10 @@ export const TemplatePage = () => { ); const handleCreateComplete = async (job: Job) => { - const componentYaml = job.metadata.remoteUrl?.replace( - /\.git$/, - '/blob/master/component-info.yaml', - ); - - if (!componentYaml) { + if (!job.metadata.catalogInfoUrl) { errorApi.post( new Error( - `Failed to find component-info.yaml file in ${job.metadata.remoteUrl}.`, + `Failed to find catalog-info.yaml file in ${job.metadata.remoteUrl}.`, ), ); return; @@ -124,7 +116,7 @@ export const TemplatePage = () => { const { entities: [createdEntity], - } = await catalogApi.addLocation('github', componentYaml); + } = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl }); setEntity((createdEntity as any) as TemplateEntityV1alpha1); }; @@ -144,7 +136,7 @@ export const TemplatePage = () => { } return ( - +
; + +class SearchApi { + private catalogApi: CatalogApi; + + constructor(catalogApi: CatalogApi) { + this.catalogApi = catalogApi; + } + + private async entities() { + const entities = await this.catalogApi.getEntities(); + return entities.items.map((result: any) => ({ + name: result.metadata.name, + description: result.metadata.description, + owner: result.spec.owner, + kind: result.kind, + lifecycle: result.spec.lifecycle, + })); + } + + public getSearchResult(): Promise { + return this.entities(); + } +} + +export default SearchApi; diff --git a/plugins/search/src/components/Filters/Filters.tsx b/plugins/search/src/components/Filters/Filters.tsx new file mode 100644 index 0000000000..19d4e53d72 --- /dev/null +++ b/plugins/search/src/components/Filters/Filters.tsx @@ -0,0 +1,132 @@ +/* + * 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 from 'react'; +import { + makeStyles, + Typography, + Divider, + Card, + CardHeader, + Button, + CardContent, + Select, + Checkbox, + List, + ListItem, + ListItemText, + MenuItem, +} from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + filters: { + background: 'transparent', + boxShadow: '0px 0px 0px 0px', + }, + checkbox: { + padding: theme.spacing(0, 1, 0, 1), + }, + dropdown: { + width: '100%', + }, +})); + +export type FiltersState = { + selected: string; + checked: Array; +}; + +type FiltersProps = { + filters: FiltersState; + resetFilters: () => void; + updateSelected: (filter: string) => void; + updateChecked: (filter: string) => void; +}; + +export const Filters = ({ + filters, + resetFilters, + updateSelected, + updateChecked, +}: FiltersProps) => { + const classes = useStyles(); + + // TODO: move mocked filters out of filters component to make it more generic + const filter1 = ['All', 'API', 'Component', 'Location', 'Template']; + const filter2 = ['deprecated', 'recommended', 'experimental', 'production']; + + return ( + + Filters} + action={ + + } + /> + + + Kind + + + + Lifecycle + + {filter2.map(filter => ( + updateChecked(filter)} + > + + + + ))} + + + + ); +}; diff --git a/plugins/search/src/components/Filters/FiltersButton.tsx b/plugins/search/src/components/Filters/FiltersButton.tsx new file mode 100644 index 0000000000..4775e9d8b8 --- /dev/null +++ b/plugins/search/src/components/Filters/FiltersButton.tsx @@ -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 React from 'react'; +import FilterListIcon from '@material-ui/icons/FilterList'; +import { makeStyles, IconButton, Typography } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + filters: { + width: '250px', + display: 'flex', + }, + icon: { + margin: theme.spacing(-1, 0, 0, 0), + }, +})); + +type FiltersButtonProps = { + numberOfSelectedFilters: number; + handleToggleFilters: () => void; +}; + +export const FiltersButton = ({ + numberOfSelectedFilters, + handleToggleFilters, +}: FiltersButtonProps) => { + const classes = useStyles(); + + return ( +
+ + + + + Filters ({numberOfSelectedFilters ? numberOfSelectedFilters : 0}) + +
+ ); +}; diff --git a/plugins/search/src/components/Filters/index.tsx b/plugins/search/src/components/Filters/index.tsx new file mode 100644 index 0000000000..ea431a3c01 --- /dev/null +++ b/plugins/search/src/components/Filters/index.tsx @@ -0,0 +1,19 @@ +/* + * 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 { FiltersButton } from './FiltersButton'; +export { Filters } from './Filters'; +export type { FiltersState } from './Filters'; diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000000..9aa48e7284 --- /dev/null +++ b/plugins/search/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,69 @@ +/* + * 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 from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import { Paper } from '@material-ui/core'; +import InputBase from '@material-ui/core/InputBase'; +import IconButton from '@material-ui/core/IconButton'; +import SearchIcon from '@material-ui/icons/Search'; +import ClearButton from '@material-ui/icons/Clear'; + +const useStyles = makeStyles(() => ({ + root: { + display: 'flex', + alignItems: 'center', + }, + input: { + flex: 1, + }, +})); + +type SearchBarProps = { + searchQuery: string; + handleSearch: any; + handleClearSearchBar: any; +}; + +export const SearchBar = ({ + searchQuery, + handleSearch, + handleClearSearchBar, +}: SearchBarProps) => { + const classes = useStyles(); + + return ( + handleSearch(e)} + className={classes.root} + > + + + + handleSearch(e)} + inputProps={{ 'aria-label': 'search backstage' }} + /> + handleClearSearchBar()}> + + + + ); +}; diff --git a/plugins/search/src/components/SearchBar/index.tsx b/plugins/search/src/components/SearchBar/index.tsx new file mode 100644 index 0000000000..e2b8af1946 --- /dev/null +++ b/plugins/search/src/components/SearchBar/index.tsx @@ -0,0 +1,17 @@ +/* + * 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 { SearchBar } from './SearchBar'; diff --git a/plugins/search/src/components/SearchPage/SearchPage.tsx b/plugins/search/src/components/SearchPage/SearchPage.tsx new file mode 100644 index 0000000000..b5779a3d6e --- /dev/null +++ b/plugins/search/src/components/SearchPage/SearchPage.tsx @@ -0,0 +1,55 @@ +/* + * 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, { useState } from 'react'; + +import { Header, Content, Page } from '@backstage/core'; +import { Grid } from '@material-ui/core'; + +import { SearchBar } from '../SearchBar'; +import { SearchResult } from '../SearchResult'; + +export const SearchPage = () => { + const [searchQuery, setSearchQuery] = useState(''); + + const handleSearch = (event: React.ChangeEvent) => { + event.preventDefault(); + setSearchQuery(event.target.value); + }; + + const handleClearSearchBar = () => { + setSearchQuery(''); + }; + + return ( + +
+ + + + + + + + + + + + ); +}; diff --git a/plugins/search/src/components/SearchPage/index.tsx b/plugins/search/src/components/SearchPage/index.tsx new file mode 100644 index 0000000000..acdc0967ab --- /dev/null +++ b/plugins/search/src/components/SearchPage/index.tsx @@ -0,0 +1,17 @@ +/* + * 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 { SearchPage } from './SearchPage'; diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx new file mode 100644 index 0000000000..e4163c0de7 --- /dev/null +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -0,0 +1,245 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { useAsync } from 'react-use'; + +import { makeStyles, Typography, Grid, Divider } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { + EmptyState, + Progress, + Table, + TableColumn, + useApi, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog'; + +import { FiltersButton, Filters, FiltersState } from '../Filters'; +import SearchApi, { Result, SearchResults } from '../../apis'; + +const useStyles = makeStyles(theme => ({ + searchTerm: { + background: '#eee', + borderRadius: '10%', + }, + tableHeader: { + margin: theme.spacing(1, 0, 0, 0), + display: 'flex', + }, + divider: { + width: '1px', + margin: theme.spacing(0, 2), + padding: theme.spacing(2, 0), + }, +})); + +type SearchResultProps = { + searchQuery?: string; +}; + +type TableHeaderProps = { + searchQuery?: string; + numberOfSelectedFilters: number; + numberOfResults: number; + handleToggleFilters: () => void; +}; + +type Filters = { + selected: string; + checked: Array; +}; + +// TODO: move out column to make the search result component more generic +const columns: TableColumn[] = [ + { + title: 'Component Id', + field: 'name', + highlight: true, + }, + { + title: 'Description', + field: 'description', + }, + { + title: 'Owner', + field: 'owner', + }, + { + title: 'Kind', + field: 'kind', + }, + { + title: 'LifeCycle', + field: 'lifecycle', + }, +]; + +const TableHeader = ({ + searchQuery, + numberOfSelectedFilters, + numberOfResults, + handleToggleFilters, +}: TableHeaderProps) => { + const classes = useStyles(); + + return ( +
+ + + + {searchQuery ? ( + + {`${numberOfResults} `} + {numberOfResults > 1 ? `results for ` : `result for `} + "{searchQuery}"{' '} + + ) : ( + {`${numberOfResults} results`} + )} + +
+ ); +}; + +export const SearchResult = ({ searchQuery }: SearchResultProps) => { + const catalogApi = useApi(catalogApiRef); + + const [showFilters, toggleFilters] = useState(false); + const [filters, setFilters] = useState({ + selected: 'All', + checked: [], + }); + + const [filteredResults, setFilteredResults] = useState([]); + + const searchApi = new SearchApi(catalogApi); + + const { loading, error, value: results } = useAsync(() => { + return searchApi.getSearchResult(); + }, []); + + useEffect(() => { + if (results) { + let withFilters = results; + + // apply filters + + // filter on selected + if (filters.selected !== 'All') { + withFilters = results.filter((result: Result) => + filters.selected.includes(result.kind), + ); + } + + // filter on checked + if (filters.checked.length > 0) { + withFilters = withFilters.filter((result: Result) => + filters.checked.includes(result.lifecycle), + ); + } + + // filter on searchQuery + if (searchQuery) { + withFilters = withFilters.filter( + (result: Result) => + result.name?.toLowerCase().includes(searchQuery) || + result.description?.toLowerCase().includes(searchQuery), + ); + } + + setFilteredResults(withFilters); + } + }, [filters, searchQuery, results]); + if (loading) { + return ; + } + if (error) { + return ( + + Error encountered while fetching search results. {error.toString()} + + ); + } + if (!results || results.length === 0) { + return ; + } + + const resetFilters = () => { + setFilters({ + selected: 'All', + checked: [], + }); + }; + + const updateSelected = (filter: string) => { + setFilters(prevState => ({ + ...prevState, + selected: filter, + })); + }; + + const updateChecked = (filter: string) => { + if (filters.checked.includes(filter)) { + setFilters(prevState => ({ + ...prevState, + checked: prevState.checked.filter(item => item !== filter), + })); + return; + } + + setFilters(prevState => ({ + ...prevState, + checked: [...prevState.checked, filter], + })); + }; + + return ( + <> + + {showFilters && ( + + + + )} + + toggleFilters(!showFilters)} + /> + } + /> + + + + ); +}; diff --git a/plugins/search/src/components/SearchResult/index.tsx b/plugins/search/src/components/SearchResult/index.tsx new file mode 100644 index 0000000000..cf10135fd0 --- /dev/null +++ b/plugins/search/src/components/SearchResult/index.tsx @@ -0,0 +1,17 @@ +/* + * 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 { SearchResult } from './SearchResult'; diff --git a/plugins/search/src/components/index.tsx b/plugins/search/src/components/index.tsx new file mode 100644 index 0000000000..f8e6a5a09e --- /dev/null +++ b/plugins/search/src/components/index.tsx @@ -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 * from './Filters'; +export * from './SearchBar'; +export * from './SearchPage'; +export * from './SearchResult'; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts new file mode 100644 index 0000000000..224e293890 --- /dev/null +++ b/plugins/search/src/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { plugin } from './plugin'; diff --git a/plugins/search/src/plugin.test.ts b/plugins/search/src/plugin.test.ts new file mode 100644 index 0000000000..92b8d5bcbf --- /dev/null +++ b/plugins/search/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * 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 { plugin } from './plugin'; + +describe('search', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts new file mode 100644 index 0000000000..44c7bcb042 --- /dev/null +++ b/plugins/search/src/plugin.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 { createPlugin, createRouteRef } from '@backstage/core'; +import { SearchPage } from './components/SearchPage'; + +export const rootRouteRef = createRouteRef({ + path: '/search', + title: 'search', +}); + +export const plugin = createPlugin({ + id: 'search', + register({ router }) { + router.addRoute(rootRouteRef, SearchPage); + }, +}); diff --git a/plugins/search/src/setupTests.ts b/plugins/search/src/setupTests.ts new file mode 100644 index 0000000000..43b8421558 --- /dev/null +++ b/plugins/search/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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 '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/sentry-backend/CHANGELOG.md b/plugins/sentry-backend/CHANGELOG.md new file mode 100644 index 0000000000..9ca0a5415e --- /dev/null +++ b/plugins/sentry-backend/CHANGELOG.md @@ -0,0 +1,26 @@ +# @backstage/plugin-sentry-backend + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [5249594c5] +- Updated dependencies [56e4eb589] +- Updated dependencies [e37c0a005] +- Updated dependencies [f00ca3cb8] +- Updated dependencies [6579769df] +- Updated dependencies [8c2b76e45] +- Updated dependencies [440a17b39] +- Updated dependencies [8afce088a] +- Updated dependencies [7bbeb049f] + - @backstage/backend-common@0.2.0 diff --git a/plugins/sentry-backend/package.json b/plugins/sentry-backend/package.json index c52f7176e3..2b6b5fc60d 100644 --- a/plugins/sentry-backend/package.json +++ b/plugins/sentry-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry-backend", - "version": "0.1.1-alpha.24", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.24", + "@backstage/backend-common": "^0.3.0", "@types/express": "^4.17.6", "axios": "^0.20.0", "compression": "^1.7.4", @@ -34,8 +34,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "jest-fetch-mock": "^3.0.3" + "@backstage/cli": "^0.3.0" }, "files": [ "dist" diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md new file mode 100644 index 0000000000..92327c1668 --- /dev/null +++ b/plugins/sentry/CHANGELOG.md @@ -0,0 +1,64 @@ +# @backstage/plugin-sentry + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] + - @backstage/core@0.3.1 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI + +### Patch Changes + +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 6972b87823..80a37c574e 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.1.1-alpha.24", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/core": "^0.3.1", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,18 +36,34 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/sentry", + "type": "object", + "properties": { + "sentry": { + "type": "object", + "properties": { + "organization": { + "type": "string", + "visibility": "frontend" + } + } + } + } + } } diff --git a/plugins/sentry/src/components/Router.tsx b/plugins/sentry/src/components/Router.tsx index 47d532b4e3..6d15268629 100644 --- a/plugins/sentry/src/components/Router.tsx +++ b/plugins/sentry/src/components/Router.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { Routes, Route } from 'react-router'; -import { WarningPanel } from '@backstage/core'; +import { MissingAnnotationEmptyState } from '@backstage/core'; import { SentryPluginWidget } from './SentryPluginWidget/SentryPluginWidget'; const SENTRY_ANNOTATION = 'sentry.io/project-slug'; @@ -25,11 +25,7 @@ export const Router = ({ entity }: { entity: Entity }) => { const projectId = entity.metadata.annotations?.[SENTRY_ANNOTATION]; if (!projectId) { - return ( - -
{SENTRY_ANNOTATION}
annotation is missing on the entity. -
- ); + return ; } return ( diff --git a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx index d508ddf7c2..21c21ce385 100644 --- a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx +++ b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx @@ -16,10 +16,13 @@ import React from 'react'; import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; import SentryPluginPage from './SentryPluginPage'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; +import { msw } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + import { ApiProvider, ApiRegistry, @@ -31,8 +34,11 @@ const errorApi = { post: () => {} }; const ConfigApi = { getString: () => 'test' }; describe('SentryPluginPage', () => { + const server = setupServer(); + msw.setupDefaultHandlers(server); + it('should render header and time switched', () => { - mockFetch.mockResponse('{}'); + server.use(rest.get('/', (_req, res, ctx) => res(ctx.json({})))); const rendered = render( = () => { const sentryProjectId = 'sample-sentry-project-id'; return ( - +
diff --git a/plugins/sentry/src/setupTests.ts b/plugins/sentry/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/sentry/src/setupTests.ts +++ b/plugins/sentry/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/sonarqube/.eslintrc.js b/plugins/sonarqube/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/sonarqube/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md new file mode 100644 index 0000000000..0296bd0416 --- /dev/null +++ b/plugins/sonarqube/CHANGELOG.md @@ -0,0 +1,52 @@ +# @backstage/plugin-sonarqube + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/sonarqube/README.md b/plugins/sonarqube/README.md new file mode 100644 index 0000000000..28bf196ea5 --- /dev/null +++ b/plugins/sonarqube/README.md @@ -0,0 +1,97 @@ +# SonarQube Plugin + +The SonarQube Plugin displays code statistics from [SonarCloud](https://sonarcloud.io) or [SonarQube](https://sonarqube.com). + +![Sonar Card](./docs/sonar-card.png) + +## Getting Started + +1. Install the SonarQube Plugin: + +```bash +# packages/app + +yarn add @backstage/plugin-sonarqube +``` + +2. Add plugin to the app: + +```js +// packages/app/src/plugins.ts + +export { plugin as SonarQube } from '@backstage/plugin-sonarqube'; +``` + +3. Add the `SonarQubeCard` to the EntityPage: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { SonarQubeCard } from '@backstage/plugin-sonarqube'; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + // ... + + + + // ... + +); +``` + +4. Add the proxy config: + +**SonarCloud** + +```yaml +# app-config.yaml + +proxy: + '/sonarqube': + target: https://sonarcloud.io/api + allowedMethods: ['GET'] + headers: + Authorization: + # Content: 'Basic base64(":")' <-- note the trailing ':' + # Example: Basic bXktYXBpLWtleTo= + $env: SONARQUBE_AUTH_HEADER +``` + +**SonarQube** + +```yaml +# app-config.yaml + +proxy: + '/sonarqube': + target: https://your.sonarqube.instance.com/api + allowedMethods: ['GET'] + headers: + Authorization: + # Content: 'Basic base64(":")' <-- note the trailing ':' + # Example: Basic bXktYXBpLWtleTo= + $env: SONARQUBE_AUTH_HEADER + +sonarQube: + baseUrl: https://your.sonarqube.instance.com +``` + +5. Get and provide `SONARQUBE_AUTH_HEADER` as env variable (https://sonarcloud.io/account/security or https://docs.sonarqube.org/latest/user-guide/user-token/) + +6. Add the `sonarqube.org/project-key` annotation to your catalog-info.yaml file: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + description: | + Backstage is an open-source developer portal that puts the developer experience first. + annotations: + sonarqube.org/project-key: YOUR_PROJECT_KEY +spec: + type: library + owner: CNCF + lifecycle: experimental +``` diff --git a/plugins/sonarqube/dev/index.tsx b/plugins/sonarqube/dev/index.tsx new file mode 100644 index 0000000000..812a5585d4 --- /dev/null +++ b/plugins/sonarqube/dev/index.tsx @@ -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. + */ + +import { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/sonarqube/docs/sonar-card.png b/plugins/sonarqube/docs/sonar-card.png new file mode 100644 index 0000000000..0104b96781 Binary files /dev/null and b/plugins/sonarqube/docs/sonar-card.png differ diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json new file mode 100644 index 0000000000..dcde8fab14 --- /dev/null +++ b/plugins/sonarqube/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-sonarqube", + "version": "0.1.3", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.2.0", + "@backstage/core": "^0.3.1", + "@backstage/theme": "^0.2.1", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@material-ui/styles": "^4.10.0", + "cross-fetch": "^3.0.6", + "rc-progress": "^3.0.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/sonarqube/src/api/index.test.ts b/plugins/sonarqube/src/api/index.test.ts new file mode 100644 index 0000000000..2d96fc2135 --- /dev/null +++ b/plugins/sonarqube/src/api/index.test.ts @@ -0,0 +1,176 @@ +/* + * 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 { UrlPatternDiscovery } from '@backstage/core'; +import { msw } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { FindingSummary, SonarQubeApi } from './index'; +import { ComponentWrapper, MeasuresWrapper } from './types'; + +const server = setupServer(); + +describe('SonarQubeApi', () => { + msw.setupDefaultHandlers(server); + + const mockBaseUrl = 'http://backstage:9191/api/proxy'; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + + const setupHandlers = () => { + server.use( + rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe('component=our-service'); + return res( + ctx.json({ + component: { + analysisDate: '2020-01-01T00:00:00Z', + }, + } as ComponentWrapper), + ); + }), + ); + + server.use( + rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density', + ); + return res( + ctx.json({ + measures: [ + { + metric: 'alert_status', + value: 'OK', + component: 'our-service', + }, + { + metric: 'alert_status', + value: 'ERROR', + component: 'other-service', + }, + { + metric: 'bugs', + value: '2', + component: 'our-service', + }, + { + metric: 'reliability_rating', + value: '3.0', + component: 'our-service', + }, + { + metric: 'vulnerabilities', + value: '4', + component: 'our-service', + }, + { + metric: 'security_rating', + value: '1.0', + component: 'our-service', + }, + { + metric: 'code_smells', + value: '100', + component: 'our-service', + }, + { + metric: 'sqale_rating', + value: '2.0', + component: 'our-service', + }, + { + metric: 'coverage', + value: '55.5', + component: 'our-service', + }, + { + metric: 'duplicated_lines_density', + value: '1.0', + component: 'our-service', + }, + ], + } as MeasuresWrapper), + ); + }), + ); + }; + + it('should report finding summary', async () => { + setupHandlers(); + + const client = new SonarQubeApi({ discoveryApi }); + + const summary = await client.getFindingSummary('our-service'); + expect(summary).toEqual( + expect.objectContaining({ + lastAnalysis: '2020-01-01T00:00:00Z', + metrics: { + alert_status: 'OK', + bugs: '2', + reliability_rating: '3.0', + vulnerabilities: '4', + security_rating: '1.0', + code_smells: '100', + sqale_rating: '2.0', + coverage: '55.5', + duplicated_lines_density: '1.0', + }, + projectUrl: 'https://sonarcloud.io/dashboard?id=our-service', + }), + ); + expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual( + 'https://sonarcloud.io/project/issues?id=our-service&types=CODE_SMELL&resolved=false', + ); + expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual( + 'https://sonarcloud.io/component_measures?id=our-service&metric=coverage&resolved=false&view=list', + ); + }); + + it('should report finding summary (custom baseUrl)', async () => { + setupHandlers(); + + const client = new SonarQubeApi({ + discoveryApi, + baseUrl: 'http://a.instance.local', + }); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual( + expect.objectContaining({ + lastAnalysis: '2020-01-01T00:00:00Z', + metrics: { + alert_status: 'OK', + bugs: '2', + reliability_rating: '3.0', + vulnerabilities: '4', + security_rating: '1.0', + code_smells: '100', + sqale_rating: '2.0', + coverage: '55.5', + duplicated_lines_density: '1.0', + }, + projectUrl: 'http://a.instance.local/dashboard?id=our-service', + }) as FindingSummary, + ); + expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual( + 'http://a.instance.local/project/issues?id=our-service&types=CODE_SMELL&resolved=false', + ); + expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual( + 'http://a.instance.local/component_measures?id=our-service&metric=coverage&resolved=false&view=list', + ); + }); +}); diff --git a/plugins/sonarqube/src/api/index.ts b/plugins/sonarqube/src/api/index.ts new file mode 100644 index 0000000000..f2c616f53c --- /dev/null +++ b/plugins/sonarqube/src/api/index.ts @@ -0,0 +1,125 @@ +/* + * 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, DiscoveryApi } from '@backstage/core'; +import fetch from 'cross-fetch'; +import { + ComponentWrapper, + MeasuresWrapper, + MetricKey, + SonarUrlProcessorFunc, +} from './types'; + +/** + * Define a type to make sure that all metrics are used + */ +type Metrics = { + [key in MetricKey]: string | undefined; +}; + +export interface FindingSummary { + lastAnalysis: string; + metrics: Metrics; + projectUrl: string; + getIssuesUrl: SonarUrlProcessorFunc; + getComponentMeasuresUrl: SonarUrlProcessorFunc; +} + +export const sonarQubeApiRef = createApiRef({ + id: 'plugin.sonarqube.service', + description: 'Used by the SonarQube plugin to make requests', +}); + +export class SonarQubeApi { + discoveryApi: DiscoveryApi; + baseUrl: string; + + constructor({ + discoveryApi, + baseUrl = 'https://sonarcloud.io/', + }: { + discoveryApi: DiscoveryApi; + baseUrl?: string; + }) { + this.discoveryApi = discoveryApi; + this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + } + + private async callApi(path: string): Promise { + const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sonarqube`; + const response = await fetch(`${apiUrl}/${path}`); + if (response.status === 200) { + return (await response.json()) as T; + } + return undefined; + } + + async getFindingSummary( + componentKey?: string, + ): Promise { + if (!componentKey) { + return undefined; + } + + const component = await this.callApi( + `components/show?component=${componentKey}`, + ); + if (!component) { + return undefined; + } + + const metrics: Metrics = { + alert_status: undefined, + bugs: undefined, + reliability_rating: undefined, + vulnerabilities: undefined, + security_rating: undefined, + code_smells: undefined, + sqale_rating: undefined, + coverage: undefined, + duplicated_lines_density: undefined, + }; + + const measures = await this.callApi( + `measures/search?projectKeys=${componentKey}&metricKeys=${Object.keys( + metrics, + ).join(',')}`, + ); + if (!measures) { + return undefined; + } + + measures.measures + .filter(m => m.component === componentKey) + .forEach(m => { + metrics[m.metric] = m.value; + }); + + return { + lastAnalysis: component.component.analysisDate, + metrics, + projectUrl: `${this.baseUrl}dashboard?id=${componentKey}`, + getIssuesUrl: identifier => + `${ + this.baseUrl + }project/issues?id=${componentKey}&types=${identifier.toUpperCase()}&resolved=false`, + getComponentMeasuresUrl: (identifier: string) => + `${ + this.baseUrl + }component_measures?id=${componentKey}&metric=${identifier.toLowerCase()}&resolved=false&view=list`, + }; + } +} diff --git a/plugins/sonarqube/src/api/types.ts b/plugins/sonarqube/src/api/types.ts new file mode 100644 index 0000000000..fc99b2d87d --- /dev/null +++ b/plugins/sonarqube/src/api/types.ts @@ -0,0 +1,57 @@ +/* + * 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 interface ComponentWrapper { + component: Component; +} + +export interface Component { + analysisDate: string; +} + +export interface MeasuresWrapper { + measures: Measure[]; +} + +export type MetricKey = + // alert status + | 'alert_status' + + // bugs and rating (-> reliability) + | 'bugs' + | 'reliability_rating' + + // vulnerabilities and rating (-> security) + | 'vulnerabilities' + | 'security_rating' + + // code smells and rating (-> maintainability) + | 'code_smells' + | 'sqale_rating' + + // coverage + | 'coverage' + + // duplicated lines + | 'duplicated_lines_density'; + +export interface Measure { + metric: MetricKey; + value: string; + component: string; +} + +export type SonarUrlProcessorFunc = (identifier: string) => string; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx new file mode 100644 index 0000000000..9d1fc415ff --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/Percentage.tsx @@ -0,0 +1,45 @@ +/* + * 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 { BackstageTheme } from '@backstage/theme'; +import { makeStyles } from '@material-ui/core/styles'; +import { useTheme } from '@material-ui/styles'; +import { Circle } from 'rc-progress'; +import React from 'react'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + root: { + height: theme.spacing(3), + width: theme.spacing(3), + }, +})); + +export const Percentage = ({ value }: { value?: string }) => { + const classes = useStyles(); + const theme = useTheme(); + + return ( + + ); +}; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/Rating.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Rating.tsx new file mode 100644 index 0000000000..df90e2b821 --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/Rating.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 { BackstageTheme } from '@backstage/theme'; +import { Avatar } from '@material-ui/core'; +import { lighten, makeStyles } from '@material-ui/core/styles'; +import { CSSProperties } from '@material-ui/styles'; +import React, { useMemo } from 'react'; + +const useStyles = makeStyles((theme: BackstageTheme) => { + const commonCardRating: CSSProperties = { + height: theme.spacing(3), + width: theme.spacing(3), + color: theme.palette.common.white, + }; + + return { + ratingDefault: { + ...commonCardRating, + background: theme.palette.status.aborted, + }, + ratingA: { + ...commonCardRating, + background: theme.palette.status.ok, + }, + ratingB: { + ...commonCardRating, + background: lighten(theme.palette.status.ok, 0.5), + }, + ratingC: { + ...commonCardRating, + background: theme.palette.status.pending, + }, + ratingD: { + ...commonCardRating, + background: theme.palette.status.warning, + }, + ratingE: { + ...commonCardRating, + background: theme.palette.error.main, + }, + }; +}); + +export const Rating = ({ + rating, + hideValue, +}: { + rating?: string; + hideValue?: boolean; +}) => { + const classes = useStyles(); + + const ratingProp = useMemo(() => { + switch (rating) { + case '1.0': + return { + name: 'A', + className: classes.ratingA, + }; + + case '2.0': + return { + name: 'B', + className: classes.ratingB, + }; + + case '3.0': + return { + name: 'C', + className: classes.ratingC, + }; + + case '4.0': + return { + name: 'D', + className: classes.ratingD, + }; + + case '5.0': + return { + name: 'E', + className: classes.ratingE, + }; + + default: + return { + name: '', + className: classes.ratingDefault, + }; + } + }, [classes, rating]); + + return ( + + {!hideValue && ratingProp.name} + + ); +}; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx new file mode 100644 index 0000000000..6a94f7e8f4 --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx @@ -0,0 +1,82 @@ +/* + * 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 { Grid, Typography, Link } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { ReactNode } from 'react'; + +const useStyles = makeStyles(theme => { + return { + root: { + margin: theme.spacing(1, 0), + }, + upper: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + cardTitle: { + textAlign: 'center', + }, + wrapIcon: { + display: 'inline-flex', + verticalAlign: 'baseline', + }, + left: { + display: 'flex', + }, + right: { + display: 'flex', + marginLeft: theme.spacing(0.5), + }, + }; +}); + +export const RatingCard = ({ + leftSlot, + rightSlot, + title, + titleIcon, + link, +}: { + leftSlot: ReactNode; + rightSlot: ReactNode; + title: string; + titleIcon?: ReactNode; + link: string; +}) => { + const classes = useStyles(); + + return ( + + + + + {leftSlot} + + + {rightSlot} + + + + + {titleIcon} {title} + + + + + ); +}; diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx new file mode 100644 index 0000000000..a68a022717 --- /dev/null +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -0,0 +1,241 @@ +/* + * 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 } from '@backstage/catalog-model'; +import { + EmptyState, + InfoCard, + MissingAnnotationEmptyState, + Progress, + useApi, +} from '@backstage/core'; +import { Chip, Grid } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import BugReport from '@material-ui/icons/BugReport'; +import LockOpen from '@material-ui/icons/LockOpen'; +import SentimentVeryDissatisfied from '@material-ui/icons/SentimentVeryDissatisfied'; +import React, { useMemo } from 'react'; +import { useAsync } from 'react-use'; +import { sonarQubeApiRef } from '../../api'; +import { + SONARQUBE_PROJECT_KEY_ANNOTATION, + useProjectKey, +} from '../useProjectKey'; +import { Percentage } from './Percentage'; +import { Rating } from './Rating'; +import { RatingCard } from './RatingCard'; +import { Value } from './Value'; + +const useStyles = makeStyles(theme => ({ + badgeLabel: { + color: theme.palette.common.white, + }, + badgeError: { + margin: 0, + backgroundColor: theme.palette.error.main, + }, + badgeSuccess: { + margin: 0, + backgroundColor: theme.palette.success.main, + }, + badgeUnknown: { + backgroundColor: theme.palette.grey[500], + }, + header: { + padding: theme.spacing(2, 2, 2, 2.5), + }, + action: { + margin: 0, + }, + lastAnalyzed: { + color: theme.palette.text.secondary, + }, + disabled: { + backgroundColor: theme.palette.background.default, + }, +})); + +interface DuplicationRating { + greaterThan: number; + rating: '1.0' | '2.0' | '3.0' | '4.0' | '5.0'; +} + +const defaultDuplicationRatings: DuplicationRating[] = [ + { greaterThan: 0, rating: '1.0' }, + { greaterThan: 3, rating: '2.0' }, + { greaterThan: 5, rating: '3.0' }, + { greaterThan: 10, rating: '4.0' }, + { greaterThan: 20, rating: '5.0' }, +]; + +export const SonarQubeCard = ({ + entity, + variant = 'gridItem', + duplicationRatings = defaultDuplicationRatings, +}: { + entity: Entity; + variant?: string; + duplicationRatings?: DuplicationRating[]; +}) => { + const sonarQubeApi = useApi(sonarQubeApiRef); + + const projectTitle = useProjectKey(entity); + + const { value, loading } = useAsync( + async () => sonarQubeApi.getFindingSummary(projectTitle), + [sonarQubeApi, projectTitle], + ); + + const deepLink = + !loading && value + ? { + title: 'View more', + link: value.projectUrl, + } + : undefined; + + const classes = useStyles(); + let gateLabel = 'Not computed'; + let gateColor = classes.badgeUnknown; + + if (value?.metrics.alert_status) { + const gatePassed = value.metrics.alert_status === 'OK'; + gateLabel = gatePassed ? 'Gate passed' : 'Gate failed'; + gateColor = gatePassed ? classes.badgeSuccess : classes.badgeError; + } + + const qualityBadge = !loading && value && ( + + ); + + const duplicationRating = useMemo(() => { + if (loading || !value || !value.metrics.duplicated_lines_density) { + return ''; + } + + let rating = ''; + + for (const r of duplicationRatings) { + if (+value.metrics.duplicated_lines_density >= r.greaterThan) { + rating = r.rating; + } + } + + return rating; + }, [loading, value, duplicationRatings]); + + return ( + + {loading && } + + {!loading && !projectTitle && ( + + )} + + {!loading && projectTitle && !value && ( + + )} + + {!loading && value && ( + <> + + + } + title="Bugs" + link={value.getIssuesUrl('BUG')} + leftSlot={} + rightSlot={} + /> + } + title="Vulnerabilities" + link={value.getIssuesUrl('VULNERABILITY')} + leftSlot={} + rightSlot={} + /> + } + title="Code Smells" + link={value.getIssuesUrl('CODE_SMELL')} + leftSlot={} + rightSlot={} + /> +
+ } + rightSlot={} + /> + } + rightSlot={ + + } + /> + + + Last analyzed on{' '} + {new Date(value.lastAnalysis).toLocaleString('en-US', { + timeZone: 'UTC', + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} + + + + )} + + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/AuthProviderList.tsx b/plugins/sonarqube/src/components/SonarQubeCard/Value.tsx similarity index 59% rename from packages/core/src/layout/Sidebar/Settings/AuthProviderList.tsx rename to plugins/sonarqube/src/components/SonarQubeCard/Value.tsx index 34cf6fb837..e7a5492e68 100644 --- a/packages/core/src/layout/Sidebar/Settings/AuthProviderList.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/Value.tsx @@ -14,16 +14,21 @@ * limitations under the License. */ +import { BackstageTheme } from '@backstage/theme'; +import { makeStyles } from '@material-ui/core/styles'; import React from 'react'; -import List from '@material-ui/core/List'; -import ListSubheader from '@material-ui/core/ListSubheader'; -type Props = { - providerSettings: React.ReactNode; +const useStyles = makeStyles((theme: BackstageTheme) => { + return { + value: { + fontSize: '1.5rem', + fontWeight: theme.typography.fontWeightMedium, + }, + }; +}); + +export const Value = ({ value }: { value?: string }) => { + const classes = useStyles(); + + return {value}; }; - -export const AuthProvidersList = ({ providerSettings }: Props) => ( - Available Auth Providers}> - {providerSettings} - -); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts b/plugins/sonarqube/src/components/SonarQubeCard/index.ts similarity index 91% rename from plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts rename to plugins/sonarqube/src/components/SonarQubeCard/index.ts index b1388143e4..e349f1e1f9 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts +++ b/plugins/sonarqube/src/components/SonarQubeCard/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './ResourceGrowthBarChart'; +export { SonarQubeCard } from './SonarQubeCard'; diff --git a/plugins/sonarqube/src/components/index.ts b/plugins/sonarqube/src/components/index.ts new file mode 100644 index 0000000000..8f0786bb8e --- /dev/null +++ b/plugins/sonarqube/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * 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 * from './SonarQubeCard'; diff --git a/plugins/sonarqube/src/components/useProjectKey.ts b/plugins/sonarqube/src/components/useProjectKey.ts new file mode 100644 index 0000000000..dcff79d972 --- /dev/null +++ b/plugins/sonarqube/src/components/useProjectKey.ts @@ -0,0 +1,23 @@ +/* + * 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 } from '@backstage/catalog-model'; + +export const SONARQUBE_PROJECT_KEY_ANNOTATION = 'sonarqube.org/project-key'; + +export const useProjectKey = (entity: Entity) => { + return entity?.metadata.annotations?.[SONARQUBE_PROJECT_KEY_ANNOTATION] ?? ''; +}; diff --git a/plugins/sonarqube/src/index.ts b/plugins/sonarqube/src/index.ts new file mode 100644 index 0000000000..f09aeb1038 --- /dev/null +++ b/plugins/sonarqube/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './components'; +export { plugin } from './plugin'; diff --git a/plugins/sonarqube/src/plugin.test.ts b/plugins/sonarqube/src/plugin.test.ts new file mode 100644 index 0000000000..32730b64c3 --- /dev/null +++ b/plugins/sonarqube/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { plugin } from './plugin'; + +describe('sonarqube', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/sonarqube/src/plugin.ts b/plugins/sonarqube/src/plugin.ts new file mode 100644 index 0000000000..154d4ee3b8 --- /dev/null +++ b/plugins/sonarqube/src/plugin.ts @@ -0,0 +1,38 @@ +/* + * 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 { + configApiRef, + createApiFactory, + createPlugin, + discoveryApiRef, +} from '@backstage/core'; +import { SonarQubeApi, sonarQubeApiRef } from './api'; + +export const plugin = createPlugin({ + id: 'sonarqube', + apis: [ + createApiFactory({ + api: sonarQubeApiRef, + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => + new SonarQubeApi({ + discoveryApi, + baseUrl: configApi.getOptionalString('sonarQube.baseUrl'), + }), + }), + ], +}); diff --git a/plugins/sonarqube/src/setupTests.ts b/plugins/sonarqube/src/setupTests.ts new file mode 100644 index 0000000000..825bcd4115 --- /dev/null +++ b/plugins/sonarqube/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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 '@testing-library/jest-dom'; diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md new file mode 100644 index 0000000000..cf6f360fe6 --- /dev/null +++ b/plugins/tech-radar/CHANGELOG.md @@ -0,0 +1,55 @@ +# @backstage/plugin-tech-radar + +## 0.3.0 + +### Minor Changes + +- a906f20e7: Added tech radar blip history backend support and normalized the data structure + +### Patch Changes + +- 3f05616bf: Make the footer color of the tech-radar work in both light and dark theme. +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI + +### Patch Changes + +- 782f3b354: add test case for Progress component +- 02c60b5f8: fix the horizontal scrolling issue in the RadarPage component +- Updated dependencies [819a70229] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/core@0.2.0 + - @backstage/theme@0.2.0 + - @backstage/test-utils@0.1.2 diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index d6067ba3e2..7f5707764b 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.1.1-alpha.24", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/test-utils": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/core": "^0.3.1", + "@backstage/test-utils": "^0.1.3", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -35,8 +35,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -45,9 +46,8 @@ "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react": "^16.9", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index 7a6e790136..0c30c9374c 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -34,11 +34,17 @@ export interface RadarQuadrant { export interface RadarEntry { key: string; // react key id: string; - moved: MovedState; - quadrant: RadarQuadrant; - ring: RadarRing; + quadrant: string; title: string; url: string; + timeline: Array; +} + +export interface RadarEntrySnapshot { + date: Date; + ringId: string; + description?: string; + moved?: MovedState; } /** diff --git a/plugins/tech-radar/src/components/RadarComponent.test.tsx b/plugins/tech-radar/src/components/RadarComponent.test.tsx index 5591d74b82..59cababbc9 100644 --- a/plugins/tech-radar/src/components/RadarComponent.test.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.test.tsx @@ -19,6 +19,7 @@ import { render, waitForElement } from '@testing-library/react'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core'; +import { act } from 'react-dom/test-utils'; import { withLogCollector } from '@backstage/test-utils'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; @@ -34,8 +35,9 @@ describe('RadarComponent', () => { }); it('should render a progress bar', async () => { - const errorApi = { post: () => {} }; + jest.useFakeTimers(); + const errorApi = { post: () => {} }; const { getByTestId, queryByTestId } = render( @@ -48,9 +50,13 @@ describe('RadarComponent', () => { , ); + act(() => { + jest.advanceTimersByTime(250); + }); expect(getByTestId('progress')).toBeInTheDocument(); await waitForElement(() => queryByTestId('tech-radar-svg')); + jest.useRealTimers(); }); it('should call the errorApi if load fails', async () => { diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 99190202b9..583a7c91e0 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -20,6 +20,7 @@ import { useAsync } from 'react-use'; import Radar from '../components/Radar'; import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api'; import getSampleData from '../sampleData'; +import { Entry } from '../utils/types'; const useTechRadarLoader = (props: TechRadarComponentProps) => { const errorApi = useApi(errorApiRef); @@ -47,6 +48,29 @@ const useTechRadarLoader = (props: TechRadarComponentProps) => { const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { const { loading, error, value: data } = useTechRadarLoader(props); + const mapToEntries = ( + loaderResponse: TechRadarLoaderResponse | undefined, + ): Array => { + return loaderResponse!.entries.map(entry => { + return { + id: entry.key, + quadrant: loaderResponse!.quadrants.find(q => q.id === entry.quadrant)!, + title: entry.title, + ring: loaderResponse!.rings.find( + r => r.id === entry.timeline[0].ringId, + )!, + history: entry.timeline.map(e => { + return { + date: e.date, + ring: loaderResponse!.rings.find(a => a.id === e.ringId)!, + description: e.description, + moved: e.moved, + }; + }), + }; + }); + }; + return ( <> {loading && } @@ -55,7 +79,7 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { {...props} rings={data!.rings} quadrants={data!.quadrants} - entries={data!.entries} + entries={mapToEntries(data)} /> )} diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx index ced1ab8054..219db714af 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx @@ -15,19 +15,19 @@ */ import React from 'react'; -import { makeStyles, Theme } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core'; export type Props = { x: number; y: number; }; -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles(theme => ({ text: { pointerEvents: 'none', userSelect: 'none', fontSize: '10px', - fill: '#000', + fill: theme.palette.text.secondary, }, })); diff --git a/plugins/tech-radar/src/components/RadarPage.test.tsx b/plugins/tech-radar/src/components/RadarPage.test.tsx index 97fae16380..ee5cf8e131 100644 --- a/plugins/tech-radar/src/components/RadarPage.test.tsx +++ b/plugins/tech-radar/src/components/RadarPage.test.tsx @@ -22,6 +22,7 @@ import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core'; import GetBBoxPolyfill from '../utils/polyfills/getBBox'; import { RadarPage } from './RadarPage'; +import { act } from 'react-dom/test-utils'; import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils'; describe('RadarPage', () => { @@ -34,6 +35,8 @@ describe('RadarPage', () => { }); it('should render a progress bar', async () => { + jest.useFakeTimers(); + const techRadarProps = { width: 1200, height: 800, @@ -48,9 +51,13 @@ describe('RadarPage', () => { ), ); + act(() => { + jest.advanceTimersByTime(250); + }); expect(getByTestId('progress')).toBeInTheDocument(); await waitForElement(() => queryByTestId('tech-radar-svg')); + jest.useRealTimers(); }); it('should render a header with a svg', async () => { diff --git a/plugins/tech-radar/src/components/RadarPage.tsx b/plugins/tech-radar/src/components/RadarPage.tsx index 519c9afeee..992266a77b 100644 --- a/plugins/tech-radar/src/components/RadarPage.tsx +++ b/plugins/tech-radar/src/components/RadarPage.tsx @@ -15,19 +15,23 @@ */ import React from 'react'; -import { Grid } from '@material-ui/core'; +import { Grid, makeStyles } from '@material-ui/core'; import { Content, ContentHeader, Page, Header, - HeaderLabel, SupportButton, - pageTheme, } from '@backstage/core'; import RadarComponent from '../components/RadarComponent'; import { TechRadarComponentProps } from '../api'; +const useStyles = makeStyles(() => ({ + overflowXScroll: { + overflowX: 'scroll', + }, +})); + export type TechRadarPageProps = TechRadarComponentProps & { title?: string; subtitle?: string; @@ -39,28 +43,28 @@ export const RadarPage = ({ subtitle, pageTitle, ...props -}: TechRadarPageProps): JSX.Element => ( - -
- - -
- - - - This is used for visualizing the official guidelines of different - areas of software development such as languages, frameworks, - infrastructure and processes. - - - - - +}: TechRadarPageProps): JSX.Element => { + const classes = useStyles(); + return ( + +
+ + + + This is used for visualizing the official guidelines of different + areas of software development such as languages, frameworks, + infrastructure and processes. + + + + + + - - - -); + + + ); +}; RadarPage.defaultProps = { title: 'Tech Radar', diff --git a/plugins/tech-radar/src/sampleData.ts b/plugins/tech-radar/src/sampleData.ts index a9fa7efd7b..914bc30931 100644 --- a/plugins/tech-radar/src/sampleData.ts +++ b/plugins/tech-radar/src/sampleData.ts @@ -35,85 +35,132 @@ quadrants.push({ id: 'process', name: 'Process' }); const entries = new Array(); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua', + }, + ], url: '#', key: 'javascript', id: 'javascript', title: 'JavaScript', - quadrant: { id: 'languages', name: 'Languages' }, + quadrant: 'languages', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + description: + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat', + }, + ], url: '#', key: 'typescript', id: 'typescript', title: 'TypeScript', - quadrant: { id: 'languages', name: 'Languages' }, + quadrant: 'languages', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'webpack', id: 'webpack', title: 'Webpack', - quadrant: { id: 'frameworks', name: 'Frameworks' }, + quadrant: 'frameworks', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'react', id: 'react', title: 'React', - quadrant: { id: 'frameworks', name: 'Frameworks' }, + quadrant: 'frameworks', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'code-reviews', id: 'code-reviews', title: 'Code Reviews', - quadrant: { id: 'process', name: 'Process' }, + quadrant: 'process', }); entries.push({ - moved: 0, + timeline: [ + { + moved: 0, + ringId: 'assess', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'mob-programming', id: 'mob-programming', title: 'Mob Programming', - quadrant: { id: 'process', name: 'Process' }, - ring: { id: 'assess', name: 'ASSESS', color: '#fbdb84' }, + quadrant: 'process', }); entries.push({ - moved: 0, + timeline: [ + { + moved: 0, + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'docs-like-code', id: 'docs-like-code', title: 'Docs-like-code', - quadrant: { id: 'process', name: 'Process' }, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + quadrant: 'process', }); entries.push({ - moved: 0, + timeline: [ + { + ringId: 'hold', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'force-push', id: 'force-push', title: 'Force push to master', - quadrant: { id: 'process', name: 'Process' }, - ring: { id: 'hold', name: 'HOLD', color: '#93c47d' }, + quadrant: 'process', }); entries.push({ - moved: 0, - ring: { id: 'use', name: 'USE', color: '#93c47d' }, + timeline: [ + { + ringId: 'use', + date: new Date('2020-08-06'), + }, + ], url: '#', key: 'github-actions', id: 'github-actions', title: 'GitHub Actions', - quadrant: { id: 'infrastructure', name: 'Infrastructure' }, + quadrant: 'infrastructure', }); export default function getSampleData(): Promise { diff --git a/plugins/tech-radar/src/setupTests.ts b/plugins/tech-radar/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/tech-radar/src/setupTests.ts +++ b/plugins/tech-radar/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/tech-radar/src/utils/types.ts b/plugins/tech-radar/src/utils/types.ts index f3933ee584..3322a67dcb 100644 --- a/plugins/tech-radar/src/utils/types.ts +++ b/plugins/tech-radar/src/utils/types.ts @@ -69,6 +69,14 @@ export type Entry = { // How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved moved?: MovedState; active?: boolean; + timeline?: Array; +}; + +export type EntrySnapshot = { + date: Date; + ring: Ring; + description?: string; + moved?: MovedState; }; // The same as Entry except quadrant/ring are declared by their string ID instead of being the actual objects diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md new file mode 100644 index 0000000000..4a3fbf6821 --- /dev/null +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -0,0 +1,104 @@ +# @backstage/plugin-techdocs-backend + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [1722cb53c] +- Updated dependencies [1722cb53c] +- Updated dependencies [7b37e6834] +- Updated dependencies [8e2effb53] + - @backstage/backend-common@0.3.0 + +## 0.2.0 + +### Minor Changes + +- 6d29605db: Change the default backend plugin mount point to /api +- 5249594c5: Add service discovery interface and implement for single host deployments + + Fixes #1847, #2596 + + Went with an interface similar to the frontend DiscoveryApi, since it's dead simple but still provides a lot of flexibility in the implementation. + + Also ended up with two different methods, one for internal endpoint discovery and one for external. The two use-cases are explained a bit more in the docs, but basically it's service-to-service vs callback URLs. + + This did get me thinking about uniqueness and that we're heading towards a global namespace for backend plugin IDs. That's probably fine, but if we're happy with that we should leverage it a bit more to simplify the backend setup. For example we'd have each plugin provide its own ID and not manually mount on paths in the backend. + + Draft until we're happy with the implementation, then I can add more docs and changelog entry. Also didn't go on a thorough hunt for places where discovery can be used, but I don't think there are many since it's been pretty awkward to do service-to-service communication. + +- 5a920c6e4: Updated naming of environment variables. New pattern [NAME]\_TOKEN for Github, Gitlab, Azure & Github enterprise access tokens. + + ### Detail: + + - Previously we have to export same token for both, catalog & scaffolder + + ```bash + export GITHUB_ACCESS_TOKEN=foo + export GITHUB_PRIVATE_TOKEN=foo + ``` + + with latest changes, only single export is sufficient. + + ```bash + export GITHUB_TOKEN=foo + export GITLAB_TOKEN=foo + export GHE_TOKEN=foo + export AZURE_TOKEN=foo + ``` + + ### list: + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Old nameNew name
GITHUB_ACCESS_TOKENGITHUB_TOKEN
GITHUB_PRIVATE_TOKENGITHUB_TOKEN
GITLAB_ACCESS_TOKENGITLAB_TOKEN
GITLAB_PRIVATE_TOKENGITLAB_TOKEN
AZURE_PRIVATE_TOKENAZURE_TOKEN
GHE_PRIVATE_TOKENGHE_TOKEN
+ +### Patch Changes + +- 22ff8fba5: Replacing the hard coded `baseApiUrl` by reading the value from configuration to enable private GitHub setup for TechDocs. +- Updated dependencies [3a4236570] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [5249594c5] +- Updated dependencies [56e4eb589] +- Updated dependencies [e37c0a005] +- Updated dependencies [a768a07fb] +- Updated dependencies [f00ca3cb8] +- Updated dependencies [6579769df] +- Updated dependencies [5adfc005e] +- Updated dependencies [8c2b76e45] +- Updated dependencies [440a17b39] +- Updated dependencies [fa56f4615] +- Updated dependencies [8afce088a] +- Updated dependencies [b3d57961c] +- Updated dependencies [7bbeb049f] + - @backstage/catalog-model@0.2.0 + - @backstage/backend-common@0.2.0 diff --git a/plugins/techdocs-backend/README.md b/plugins/techdocs-backend/README.md index b7eca2c5cb..625f0910b5 100644 --- a/plugins/techdocs-backend/README.md +++ b/plugins/techdocs-backend/README.md @@ -33,7 +33,7 @@ Currently the build process of techdocs-backend is split up in these three stage - Generators - Publishers -Preparers read your entity data and creates a working directory with your documentation source code. For example if you have set your `backstage.io/techdocs-ref` to `github:https://github.com/spotify/backstage.git` it will clone that repository to a temp folder and pass that on to the generator. +Preparers read your entity data and creates a working directory with your documentation source code. For example if you have set your `backstage.io/techdocs-ref` to `github:https://github.com/backstage/backstage.git` it will clone that repository to a temp folder and pass that on to the generator. Generators takes the prepared source and runs the `techdocs-container` on it. It then passes on the output folder of that build to the publisher. @@ -43,5 +43,5 @@ Any of these can be extended. If we want to publish to a external static file se ## Links -- [Frontend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/techdocs) +- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/techdocs) - [The Backstage homepage](https://backstage.io) diff --git a/plugins/techdocs-backend/examples/documented-component/documented-component.yaml b/plugins/techdocs-backend/examples/documented-component/documented-component.yaml index 045ff89013..800116344f 100644 --- a/plugins/techdocs-backend/examples/documented-component/documented-component.yaml +++ b/plugins/techdocs-backend/examples/documented-component/documented-component.yaml @@ -4,7 +4,7 @@ metadata: name: documented-component description: A Service with TechDocs documentation annotations: - backstage.io/techdocs-ref: 'github:https://github.com/spotify/backstage/blob/master/plugins/techdocs-backend/examples/documented-component' + backstage.io/techdocs-ref: 'github:https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component' spec: type: service lifecycle: experimental diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 75f6071464..b139bb9acf 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.1.1-alpha.24", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,26 +20,24 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.24", - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/config": "^0.1.1-alpha.24", + "@backstage/backend-common": "^0.3.0", + "@backstage/catalog-model": "^0.2.0", + "@backstage/config": "^0.1.1", "@types/dockerode": "^2.5.34", "@types/express": "^4.17.6", "command-exists-promise": "^2.0.2", - "default-branch": "^1.0.8", + "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", - "git-url-parse": "^11.2.0", - "knex": "^0.21.1", - "node-fetch": "^2.6.0", + "git-url-parse": "^11.4.0", + "knex": "^0.21.6", "nodegit": "^0.27.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@types/node-fetch": "^2.5.7", + "@backstage/cli": "^0.3.0", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/techdocs-backend/src/default-branch.ts b/plugins/techdocs-backend/src/default-branch.ts index 05a8f666ce..1090fcb3d3 100644 --- a/plugins/techdocs-backend/src/default-branch.ts +++ b/plugins/techdocs-backend/src/default-branch.ts @@ -13,10 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import fetch, { RequestInit } from 'node-fetch'; +import fetch from 'cross-fetch'; import parseGitUrl from 'git-url-parse'; -import { ConfigReader, Config } from '@backstage/config'; -import { loadBackendConfig } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { getRootLogger, loadBackendConfig } from '@backstage/backend-common'; +import { + getAzureHostToken, + getGitHost, + getGithubHostToken, + getGitlabHostToken, + getGitRepoType, +} from './git-auth'; interface IGitlabBranch { name: string; @@ -42,9 +49,17 @@ interface IGitlabBranch { }; } -function getGithubApiUrl(url: string): URL { +function getGithubApiUrl(config: Config, url: string): URL { const { protocol, owner, name } = parseGitUrl(url); - const apiBaseUrl = 'api.github.com'; + const providerConfigs = + config.getOptionalConfigArray('integrations.github') ?? []; + + // TODO: Maybe we need to filter by host in the array, not sure about GHE + const targetProviderConfig = providerConfigs[0]; + + const apiBaseUrl = + targetProviderConfig?.getOptionalString('integrations.github.apiBaseUrl') ?? + 'api.github.com'; const apiRepos = 'repos'; return new URL(`${protocol}://${apiBaseUrl}/${apiRepos}/${owner}/${name}`); @@ -61,15 +76,22 @@ function getGitlabApiUrl(url: string): URL { ); } -function getGithubRequestOptions(config: Config): RequestInit { +function getAzureApiUrl(url: string): URL { + const { protocol, resource, organization, owner, name } = parseGitUrl(url); + const apiRepoPath = '_apis/git/repositories'; + const apiVersion = 'api-version=6.0'; + + return new URL( + `${protocol}://${resource}/${organization}/${owner}/${apiRepoPath}/${name}?${apiVersion}`, + ); +} + +function getGithubRequestOptions(config: Config, host: string): RequestInit { const headers: HeadersInit = { Accept: 'application/vnd.github.v3.raw', }; - const token = - config.getOptionalString('catalog.processors.github.privateToken') ?? - config.getOptionalString('catalog.processors.githubApi.privateToken') ?? - process.env.GITHUB_PRIVATE_TOKEN; + const token = getGithubHostToken(config, host); if (token) { headers.Authorization = `token ${token}`; @@ -80,16 +102,12 @@ function getGithubRequestOptions(config: Config): RequestInit { }; } -function getGitlabRequestOptions(config: Config): RequestInit { +function getGitlabRequestOptions(config: Config, host: string): RequestInit { const headers: HeadersInit = { 'PRIVATE-TOKEN': '', }; - const token = - config.getOptionalString('catalog.processors.gitlab.privateToken') ?? - config.getOptionalString('catalog.processors.gitlabApi.privateToken') ?? - process.env.GITLAB_ACCESS_TOKEN; - + const token = getGitlabHostToken(config, host); if (token) { headers['PRIVATE-TOKEN'] = token; } @@ -99,12 +117,31 @@ function getGitlabRequestOptions(config: Config): RequestInit { }; } +function getAzureRequestOptions(config: Config, host: string): RequestInit { + const headers: HeadersInit = {}; + + const token = getAzureHostToken(config, host); + + if (token !== '') { + headers.Authorization = `Basic ${Buffer.from(`:${token}`, 'utf8').toString( + 'base64', + )}`; + } + + const requestOptions: RequestInit = { + headers, + }; + + return requestOptions; +} + async function getGithubDefaultBranch( repositoryUrl: string, config: Config, ): Promise { - const path = getGithubApiUrl(repositoryUrl).toString(); - const options = getGithubRequestOptions(config); + const path = getGithubApiUrl(config, repositoryUrl).toString(); + const host = getGitHost(repositoryUrl); + const options = getGithubRequestOptions(config, host); try { const raw = await fetch(path, options); @@ -133,7 +170,8 @@ async function getGitlabDefaultBranch( ): Promise { const path = getGitlabApiUrl(repositoryUrl).toString(); - const options = getGitlabRequestOptions(config); + const gitlabHost = getGitHost(repositoryUrl); + const options = getGitlabRequestOptions(config, gitlabHost); try { const raw = await fetch(path, options); @@ -159,17 +197,51 @@ async function getGitlabDefaultBranch( } } +async function getAzureDefaultBranch( + repositoryUrl: string, + config: Config, +): Promise { + const path = getAzureApiUrl(repositoryUrl).toString(); + const host = getGitHost(repositoryUrl); + const options = getAzureRequestOptions(config, host); + + try { + const urlResponse = await fetch(path, options); + if (!urlResponse.ok) { + throw new Error( + `Failed to load url: ${urlResponse.status} ${urlResponse.statusText}. Make sure you have permission to repository: ${repositoryUrl}`, + ); + } + const urlResult = await urlResponse.json(); + + const idResponse = await fetch(urlResult.url, options); + if (!idResponse.ok) { + throw new Error( + `Failed to load url: ${idResponse.status} ${idResponse.statusText}. Make sure you have permission to repository: ${urlResult.repository.url}`, + ); + } + const idResult = await idResponse.json(); + const name = idResult.defaultBranch; + + if (!name) { + throw new Error('Not found Azure DevOps default branch'); + } + + return name; + } catch (error) { + throw new Error(`Failed to get Azure DevOps default branch: ${error}`); + } +} + export const getDefaultBranch = async ( repositoryUrl: string, ): Promise => { - const config = ConfigReader.fromConfigs(await loadBackendConfig()); - const typeMapping = [ - { url: /github*/g, type: 'github' }, - { url: /gitlab*/g, type: 'gitlab' }, - ]; - - const type = typeMapping.filter(item => item.url.test(repositoryUrl))[0] - ?.type; + // TODO(Rugvip): Config should not be loaded here, pass it in instead + const config = await loadBackendConfig({ + logger: getRootLogger(), + argv: process.argv, + }); + const type = getGitRepoType(repositoryUrl); try { switch (type) { @@ -177,6 +249,8 @@ export const getDefaultBranch = async ( return await getGithubDefaultBranch(repositoryUrl, config); case 'gitlab': return await getGitlabDefaultBranch(repositoryUrl, config); + case 'azure/api': + return await getAzureDefaultBranch(repositoryUrl, config); default: throw new Error('Failed to get repository type'); diff --git a/plugins/techdocs-backend/src/git-auth.ts b/plugins/techdocs-backend/src/git-auth.ts new file mode 100644 index 0000000000..b580a6f05b --- /dev/null +++ b/plugins/techdocs-backend/src/git-auth.ts @@ -0,0 +1,120 @@ +/* + * 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 parseGitUrl from 'git-url-parse'; +import { Config } from '@backstage/config'; +import { getRootLogger, loadBackendConfig } from '@backstage/backend-common'; + +export function getGitHost(url: string): string { + const { resource } = parseGitUrl(url); + return resource; +} + +export function getGitRepoType(url: string): string { + const typeMapping = [ + { url: /github*/g, type: 'github' }, + { url: /gitlab*/g, type: 'gitlab' }, + { url: /azure*/g, type: 'azure/api' }, + ]; + + const type = typeMapping.filter(item => item.url.test(url))[0]?.type; + + return type; +} + +export function getGithubHostToken( + config: Config, + host: string, +): string | undefined { + const providerConfigs = + config.getOptionalConfigArray('integrations.github') ?? []; + + const hostConfig = providerConfigs.filter( + providerConfig => providerConfig.getOptionalString('host') === host, + ); + const token = + hostConfig[0]?.getOptionalString('token') ?? + config.getOptionalString('catalog.processors.github.privateToken') ?? + config.getOptionalString('catalog.processors.githubApi.privateToken') ?? + process.env.GITHUB_TOKEN; + + return token; +} + +export function getGitlabHostToken( + config: Config, + host: string, +): string | undefined { + const providerConfigs = + config.getOptionalConfigArray('integrations.gitlab') ?? []; + + const hostConfig = providerConfigs.filter( + providerConfig => providerConfig.getOptionalString('host') === host, + ); + const token = + hostConfig[0]?.getOptionalString('token') ?? + config.getOptionalString('catalog.processors.gitlab.privateToken') ?? + config.getOptionalString('catalog.processors.gitlabApi.privateToken') ?? + process.env.GITLAB_TOKEN; + + return token; +} + +export function getAzureHostToken( + config: Config, + host: string, +): string | undefined { + const providerConfigs = + config.getOptionalConfigArray('integrations.azure') ?? []; + + const hostConfig = providerConfigs.filter( + providerConfig => providerConfig.getOptionalString('host') === host, + ); + const token = + hostConfig[0]?.getOptionalString('token') ?? + config.getOptionalString('catalog.processors.azureApi.privateToken') ?? + process.env.AZURE_TOKEN; + + return token; +} + +export const getTokenForGitRepo = async ( + repositoryUrl: string, +): Promise => { + // TODO(Rugvip): Config should not be loaded here, pass it in instead + const config = await loadBackendConfig({ + logger: getRootLogger(), + argv: process.argv, + }); + + const host = getGitHost(repositoryUrl); + const type = getGitRepoType(repositoryUrl); + + try { + switch (type) { + case 'github': + return getGithubHostToken(config, host); + case 'gitlab': + return getGitlabHostToken(config, host); + case 'azure/api': + return getAzureHostToken(config, host); + + default: + throw new Error('Failed to get repository type'); + } + } catch (error) { + throw error; + } +}; diff --git a/plugins/techdocs-backend/src/helpers.test.ts b/plugins/techdocs-backend/src/helpers.test.ts new file mode 100644 index 0000000000..10518df74f --- /dev/null +++ b/plugins/techdocs-backend/src/helpers.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { Readable } from 'stream'; +import { getDocFilesFromRepository } from './helpers'; +import { UrlReader, ReadTreeResponse } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; + +describe('getDocFilesFromRepository', () => { + it('should read a remote directory using UrlReader.readTree', async () => { + class MockUrlReader implements UrlReader { + async read() { + return Buffer.from('mock'); + } + + async readTree(): Promise { + return { + dir: async () => { + return '/tmp/testfolder'; + }, + files: async () => { + return []; + }, + archive: async () => { + return Readable.from(''); + }, + }; + } + } + + const mockEntity: Entity = { + metadata: { + namespace: 'default', + annotations: { + 'backstage.io/techdocs-ref': + 'url:https://github.com/backstage/backstage/blob/master/subfolder/', + }, + name: 'mytestcomponent', + description: 'A component for testing', + }, + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + spec: { + type: 'documentation', + lifecycle: 'experimental', + owner: 'testuser', + }, + }; + + const output = await getDocFilesFromRepository( + new MockUrlReader(), + mockEntity, + ); + + expect(output).toBe('/tmp/testfolder'); + }); +}); diff --git a/plugins/techdocs-backend/src/helpers.ts b/plugins/techdocs-backend/src/helpers.ts index d6a275f183..901aea533a 100644 --- a/plugins/techdocs-backend/src/helpers.ts +++ b/plugins/techdocs-backend/src/helpers.ts @@ -20,8 +20,9 @@ import parseGitUrl from 'git-url-parse'; import NodeGit, { Clone, Repository } from 'nodegit'; import fs from 'fs-extra'; import { getDefaultBranch } from './default-branch'; +import { getGitRepoType, getTokenForGitRepo } from './git-auth'; import { Entity } from '@backstage/catalog-model'; -import { InputError } from '@backstage/backend-common'; +import { InputError, UrlReader } from '@backstage/backend-common'; import { RemoteProtocol } from './techdocs/stages/prepare/types'; import { Logger } from 'winston'; @@ -76,6 +77,8 @@ export const getLocationForEntity = ( switch (type) { case 'github': case 'gitlab': + case 'azure/api': + case 'url': return { type, target }; case 'dir': if (path.isAbsolute(target)) return { type, target }; @@ -119,16 +122,7 @@ export const checkoutGitRepository = async ( ): Promise => { const parsedGitLocation = parseGitUrl(repoUrl); const repositoryTmpPath = await getGitRepositoryTempFolder(repoUrl); - - // TODO: Should propably not be hardcoded names of env variables, but seems too hard to access config down here - const user = - process.env.GITHUB_PRIVATE_TOKEN_USER || - process.env.GITLAB_PRIVATE_TOKEN_USER || - ''; - const token = - process.env.GITHUB_PRIVATE_TOKEN || - process.env.GITLAB_PRIVATE_TOKEN_USER || - ''; + const token = await getTokenForGitRepo(repoUrl); if (fs.existsSync(repositoryTmpPath)) { try { @@ -150,8 +144,10 @@ export const checkoutGitRepository = async ( } } - if (user && token) { - parsedGitLocation.token = `${user}:${token}`; + if (token) { + const type = getGitRepoType(repoUrl); + const auth = type === 'github' ? `${token}:x-oauth-basic` : `:${token}`; + parsedGitLocation.token = auth; } const repositoryCheckoutUrl = parsedGitLocation.toString('https'); @@ -173,3 +169,17 @@ export const getLastCommitTimestamp = async ( return commit.date().getTime(); }; + +export const getDocFilesFromRepository = async ( + reader: UrlReader, + entity: Entity, +): Promise => { + const { target } = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + entity, + ); + + const response = await reader.readTree(target); + + return await response.dir(); +}; diff --git a/plugins/techdocs-backend/src/service/helpers.ts b/plugins/techdocs-backend/src/service/helpers.ts index 1b8e130ec0..3017a68461 100644 --- a/plugins/techdocs-backend/src/service/helpers.ts +++ b/plugins/techdocs-backend/src/service/helpers.ts @@ -66,22 +66,16 @@ export class DocsBuilder { } public async build() { - this.logger.info( - `[TechDocs] Running preparer on entity ${getEntityId(this.entity)}`, - ); + this.logger.info(`Running preparer on entity ${getEntityId(this.entity)}`); const preparedDir = await this.preparer.prepare(this.entity); - this.logger.info( - `[TechDocs] Running generator on entity ${getEntityId(this.entity)}`, - ); + this.logger.info(`Running generator on entity ${getEntityId(this.entity)}`); const { resultDir } = await this.generator.run({ directory: preparedDir, dockerClient: this.dockerClient, }); - this.logger.info( - `[TechDocs] Running publisher on entity ${getEntityId(this.entity)}`, - ); + this.logger.info(`Running publisher on entity ${getEntityId(this.entity)}`); await this.publisher.publish({ entity: this.entity, directory: resultDir, @@ -109,7 +103,7 @@ export class DocsBuilder { const { type, target } = getLocationForEntity(this.entity); // Unless docs are stored locally - const nonAgeCheckTypes = ['dir', 'file']; + const nonAgeCheckTypes = ['dir', 'file', 'url']; if (!nonAgeCheckTypes.includes(type)) { const lastCommit = await getLastCommitTimestamp(target, this.logger); const storageTimeStamp = buildMetadataStorage.getTimestamp(); @@ -117,16 +111,14 @@ export class DocsBuilder { // Check if documentation source is newer than what we have if (storageTimeStamp && storageTimeStamp >= lastCommit) { this.logger.debug( - `[TechDocs] Docs for entity ${getEntityId( - this.entity, - )} is up to date.`, + `Docs for entity ${getEntityId(this.entity)} is up to date.`, ); return true; } } this.logger.debug( - `[TechDocs] Docs for entity ${getEntityId(this.entity)} was outdated.`, + `Docs for entity ${getEntityId(this.entity)} was outdated.`, ); return false; } diff --git a/plugins/techdocs-backend/src/service/metadata.ts b/plugins/techdocs-backend/src/service/metadata.ts index 671fcdda17..760180f8d2 100644 --- a/plugins/techdocs-backend/src/service/metadata.ts +++ b/plugins/techdocs-backend/src/service/metadata.ts @@ -1,4 +1,3 @@ -import fetch from 'node-fetch'; /* * Copyright 2020 Spotify AB * @@ -15,6 +14,8 @@ import fetch from 'node-fetch'; * limitations under the License. */ +import fetch from 'cross-fetch'; + export class TechDocsMetadata { private async getMetadataFile(docsUrl: String) { const metadataURL = `${docsUrl}/techdocs_metadata.json`; diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 365534b9d0..1d932a1fb9 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -17,7 +17,7 @@ import { Logger } from 'winston'; import Router from 'express-promise-router'; import express from 'express'; import Knex from 'knex'; -import fetch from 'node-fetch'; +import fetch from 'cross-fetch'; import { Config } from '@backstage/config'; import Docker from 'dockerode'; import { @@ -62,7 +62,13 @@ export async function createRouter({ const router = Router(); router.get('/metadata/mkdocs/*', async (req, res) => { - const storageUrl = config.getString('techdocs.storageUrl'); + let storageUrl = config.getString('techdocs.storageUrl'); + if (publisher instanceof LocalPublish) { + storageUrl = new URL( + new URL(storageUrl).pathname, + await discovery.getBaseUrl('techdocs'), + ).toString(); + } const { '0': path } = req.params; const metadataURL = `${storageUrl}/${path}/techdocs_metadata.json`; @@ -71,21 +77,20 @@ export async function createRouter({ const mkDocsMetadata = await (await fetch(metadataURL)).json(); res.send(mkDocsMetadata); } catch (err) { - logger.info( - `[TechDocs] Unable to get metadata for ${path} with error ${err}`, - ); + logger.info(`Unable to get metadata for ${path} with error ${err}`); throw new Error(`Unable to get metadata for ${path} with error ${err}`); } }); - router.get('/metadata/entity/:kind/:namespace/:name', async (req, res) => { - const baseUrl = config.getString('backend.baseUrl'); + router.get('/metadata/entity/:namespace/:kind/:name', async (req, res) => { + const catalogUrl = await discovery.getBaseUrl('catalog'); + const { kind, namespace, name } = req.params; try { const entity = (await ( await fetch( - `${baseUrl}/api/catalog/entities/by-name/${kind}/${namespace}/${name}`, + `${catalogUrl}/entities/by-name/${kind}/${namespace}/${name}`, ) ).json()) as Entity; @@ -93,7 +98,7 @@ export async function createRouter({ res.send({ ...entity, locationMetadata }); } catch (err) { logger.info( - `[TechDocs] Unable to get metadata for ${kind}/${namespace}/${name} with error ${err}`, + `Unable to get metadata for ${kind}/${namespace}/${name} with error ${err}`, ); throw new Error( `Unable to get metadata for ${kind}/${namespace}/${name} with error ${err}`, @@ -101,7 +106,7 @@ export async function createRouter({ } }); - router.get('/docs/:kind/:namespace/:name/*', async (req, res) => { + router.get('/docs/:namespace/:kind/:name/*', async (req, res) => { const storageUrl = config.getString('techdocs.storageUrl'); const { kind, namespace, name } = req.params; @@ -111,7 +116,9 @@ export async function createRouter({ const catalogRes = await fetch(`${catalogUrl}/entities/by-name/${triple}`); if (!catalogRes.ok) { - catalogRes.body.pipe(res.status(catalogRes.status)); + const catalogResText = await catalogRes.text(); + res.status(catalogRes.status); + res.send(catalogResText); return; } diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 79e8b0b945..4082a36172 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -53,7 +53,7 @@ export async function startStandaloneServer( const techdocsGenerator = new TechdocsGenerator(logger, config); generators.register('techdocs', techdocsGenerator); - const publisher = new LocalPublish(logger); + const publisher = new LocalPublish(logger, discovery); const dockerClient = new Docker(); diff --git a/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts b/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts index ac73963f9a..1160ff1ade 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/generate/techdocs.ts @@ -83,7 +83,7 @@ export class TechdocsGenerator implements GeneratorBase { logStream, }); this.logger.info( - `[TechDocs]: Successfully generated docs from ${directory} into ${resultDir} using local mkdocs`, + `Successfully generated docs from ${directory} into ${resultDir} using local mkdocs`, ); break; case 'docker': @@ -96,7 +96,7 @@ export class TechdocsGenerator implements GeneratorBase { dockerClient, }); this.logger.info( - `[TechDocs]: Successfully generated docs from ${directory} into ${resultDir} using techdocs-container`, + `Successfully generated docs from ${directory} into ${resultDir} using techdocs-container`, ); break; default: @@ -106,9 +106,9 @@ export class TechdocsGenerator implements GeneratorBase { } } catch (error) { this.logger.debug( - `[TechDocs]: Failed to generate docs from ${directory} into ${resultDir}`, + `Failed to generate docs from ${directory} into ${resultDir}`, ); - this.logger.debug(`[TechDocs]: Build failed with error: ${log}`); + this.logger.debug(`Build failed with error: ${log}`); throw new Error( `Failed to generate docs from ${directory} into ${resultDir} with error ${error.message}`, ); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts similarity index 57% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.test.ts rename to plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts index 75cad50224..843df476e9 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.test.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.test.ts @@ -15,7 +15,7 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { GitlabPreparer } from './gitlab'; +import { CommonGitPreparer } from './commonGit'; import { checkoutGitRepository } from '../../../helpers'; function normalizePath(path: string) { @@ -45,20 +45,49 @@ const createMockEntity = (annotations = {}) => { const logger = getVoidLogger(); -describe('gitlab preparer', () => { - it('should prepare temp docs path from gitlab repo', async () => { - const preparer = new GitlabPreparer(logger); +describe('commonGit preparer', () => { + it('should prepare temp docs path from github repo', async () => { + const preparer = new CommonGitPreparer(logger); + + const mockEntity = createMockEntity({ + 'backstage.io/techdocs-ref': + 'github:https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component', + }); + + const tempDocsPath = await preparer.prepare(mockEntity); + expect(checkoutGitRepository).toHaveBeenCalledTimes(1); + expect(normalizePath(tempDocsPath)).toEqual( + '/tmp/backstage-repo/org/name/branch/plugins/techdocs-backend/examples/documented-component', + ); + }); + + it('should prepare temp docs path from gitlab repo', async () => { + const preparer = new CommonGitPreparer(logger); - // TODO: fix url repo const mockEntity = createMockEntity({ 'backstage.io/techdocs-ref': 'gitlab:https://gitlab.com/xesjkeee/go-logger/blob/master/catalog-info.yaml', }); const tempDocsPath = await preparer.prepare(mockEntity); - expect(checkoutGitRepository).toHaveBeenCalledTimes(1); + expect(checkoutGitRepository).toHaveBeenCalledTimes(2); expect(normalizePath(tempDocsPath)).toEqual( '/tmp/backstage-repo/org/name/branch/catalog-info.yaml', ); }); + + it('should prepare temp docs path from azure repo', async () => { + const preparer = new CommonGitPreparer(logger); + + const mockEntity = createMockEntity({ + 'backstage.io/techdocs-ref': + 'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', + }); + + const tempDocsPath = await preparer.prepare(mockEntity); + expect(checkoutGitRepository).toHaveBeenCalledTimes(3); + expect(normalizePath(tempDocsPath)).toEqual( + '/tmp/backstage-repo/org/name/branch/template.yaml', + ); + }); }); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts similarity index 83% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts rename to plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts index a0f91a375a..d9ba96a031 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/commonGit.ts @@ -15,7 +15,6 @@ */ import path from 'path'; import { Entity } from '@backstage/catalog-model'; -import { InputError } from '@backstage/backend-common'; import { PreparerBase } from './types'; import parseGitUrl from 'git-url-parse'; import { @@ -25,7 +24,7 @@ import { import { Logger } from 'winston'; -export class GithubPreparer implements PreparerBase { +export class CommonGitPreparer implements PreparerBase { private readonly logger: Logger; constructor(logger: Logger) { @@ -33,19 +32,15 @@ export class GithubPreparer implements PreparerBase { } async prepare(entity: Entity): Promise { - const { type, target } = parseReferenceAnnotation( + const { target } = parseReferenceAnnotation( 'backstage.io/techdocs-ref', entity, ); - if (type !== 'github') { - throw new InputError(`Wrong target type: ${type}, should be 'github'`); - } - try { const repoPath = await checkoutGitRepository(target, this.logger); - const parsedGitLocation = parseGitUrl(target); + return path.join(repoPath, parsedGitLocation.filepath); } catch (error) { this.logger.debug(`Repo checkout failed with error ${error.message}`); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts index 4412e6bf73..dc2b1d7d48 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.test.ts @@ -78,7 +78,7 @@ describe('directory preparer', () => { const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': - 'github:https://github.com/spotify/backstage/blob/master/catalog-info.yaml', + 'github:https://github.com/backstage/backstage/blob/master/catalog-info.yaml', 'backstage.io/techdocs-ref': 'dir:./docs', }); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts index cad8a7effa..1effcf61b5 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/dir.ts @@ -38,11 +38,12 @@ export class DirectoryPreparer implements PreparerBase { ); this.logger.debug( - `[TechDocs] Building docs for entity with type 'dir' and managed-by-location '${type}'`, + `Building docs for entity with type 'dir' and managed-by-location '${type}'`, ); switch (type) { case 'github': - case 'gitlab': { + case 'gitlab': + case 'azure/api': { const parsedGitLocation = parseGitUrl(target); const repoLocation = await checkoutGitRepository(target, this.logger); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts deleted file mode 100644 index a657cd1697..0000000000 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getVoidLogger } from '@backstage/backend-common'; -import { GithubPreparer } from './github'; -import { checkoutGitRepository } from '../../../helpers'; - -function normalizePath(path: string) { - return path - .replace(/^[a-z]:/i, '') - .split('\\') - .join('/'); -} - -jest.mock('../../../helpers', () => ({ - ...jest.requireActual<{}>('../../../helpers'), - checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch'), -})); - -const createMockEntity = (annotations = {}) => { - return { - apiVersion: 'version', - kind: 'TestKind', - metadata: { - name: 'test-component-name', - annotations: { - ...annotations, - }, - }, - }; -}; - -const logger = getVoidLogger(); - -describe('github preparer', () => { - it('should prepare temp docs path from github repo', async () => { - const preparer = new GithubPreparer(logger); - - const mockEntity = createMockEntity({ - 'backstage.io/techdocs-ref': - 'github:https://github.com/spotify/backstage/blob/master/plugins/techdocs-backend/examples/documented-component', - }); - - const tempDocsPath = await preparer.prepare(mockEntity); - expect(checkoutGitRepository).toHaveBeenCalledTimes(1); - expect(normalizePath(tempDocsPath)).toEqual( - '/tmp/backstage-repo/org/name/branch/plugins/techdocs-backend/examples/documented-component', - ); - }); -}); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts index 9e91958bd7..6c73725a3a 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ export { DirectoryPreparer } from './dir'; -export { GithubPreparer } from './github'; -export { GitlabPreparer } from './gitlab'; +export { CommonGitPreparer } from './commonGit'; +export { UrlPreparer } from './url'; export { Preparers } from './preparers'; export type { PreparerBuilder, PreparerBase } from './types'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts index 4cb1e1e006..97e75306c3 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/types.ts @@ -30,4 +30,10 @@ export type PreparerBuilder = { get(entity: Entity): PreparerBase; }; -export type RemoteProtocol = 'dir' | 'github' | 'gitlab' | 'file'; +export type RemoteProtocol = + | 'dir' + | 'github' + | 'gitlab' + | 'file' + | 'azure/api' + | 'url'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/url.ts similarity index 53% rename from plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.ts rename to plugins/techdocs-backend/src/techdocs/stages/prepare/url.ts index 56f3793fbb..330db05aa4 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/gitlab.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/url.ts @@ -13,42 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import path from 'path'; import { Entity } from '@backstage/catalog-model'; -import { InputError } from '@backstage/backend-common'; import { PreparerBase } from './types'; -import parseGitUrl from 'git-url-parse'; -import { - parseReferenceAnnotation, - checkoutGitRepository, -} from '../../../helpers'; +import { getDocFilesFromRepository } from '../../../helpers'; import { Logger } from 'winston'; +import { UrlReader } from '@backstage/backend-common'; -export class GitlabPreparer implements PreparerBase { +export class UrlPreparer implements PreparerBase { private readonly logger: Logger; + private readonly reader: UrlReader; - constructor(logger: Logger) { + constructor(reader: UrlReader, logger: Logger) { this.logger = logger; + this.reader = reader; } async prepare(entity: Entity): Promise { - const { type, target } = parseReferenceAnnotation( - 'backstage.io/techdocs-ref', - entity, - ); - - if (type !== 'gitlab') { - throw new InputError(`Wrong target type: ${type}, should be 'gitlab'`); - } - try { - const repoPath = await checkoutGitRepository(target, this.logger); - const parsedGitLocation = parseGitUrl(target); - - return path.join(repoPath, parsedGitLocation.filepath); + return getDocFilesFromRepository(this.reader, entity); } catch (error) { - this.logger.debug(`Repo checkout failed with error ${error.message}`); + this.logger.debug( + `Unable to fetch files for building docs ${error.message}`, + ); throw error; } } diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts index 55e0a547e9..18344489e1 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/publish/local.test.ts @@ -13,9 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +/* eslint-disable no-restricted-syntax */ import fs from 'fs-extra'; import path from 'path'; -import { getVoidLogger } from '@backstage/backend-common'; +import { + getVoidLogger, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; import { LocalPublish } from './local'; const createMockEntity = (annotations = {}) => { @@ -35,7 +40,12 @@ const logger = getVoidLogger(); describe('local publisher', () => { it('should publish generated documentation dir', async () => { - const publisher = new LocalPublish(logger); + const testDiscovery: jest.Mocked = { + getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7000'), + getExternalBaseUrl: jest.fn(), + }; + + const publisher = new LocalPublish(logger, testDiscovery); const mockEntity = createMockEntity(); @@ -53,7 +63,7 @@ describe('local publisher', () => { const resultDir = path.resolve( __dirname, - `../../../../static/docs/${mockEntity.kind}/default/${mockEntity.metadata.name}`, + `../../../../static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`, ); expect(fs.existsSync(resultDir)).toBeTruthy(); diff --git a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts index f328443965..07cef2f4d2 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/publish/local.ts @@ -17,13 +17,18 @@ import fs from 'fs-extra'; import { Logger } from 'winston'; import { Entity } from '@backstage/catalog-model'; import { PublisherBase } from './types'; -import { resolvePackagePath } from '@backstage/backend-common'; +import { + resolvePackagePath, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; export class LocalPublish implements PublisherBase { private readonly logger: Logger; + private readonly discovery: PluginEndpointDiscovery; - constructor(logger: Logger) { + constructor(logger: Logger, discovery: PluginEndpointDiscovery) { this.logger = logger; + this.discovery = discovery; } publish({ @@ -42,8 +47,8 @@ export class LocalPublish implements PublisherBase { const publishDir = resolvePackagePath( '@backstage/plugin-techdocs-backend', 'static/docs', - entity.kind, entityNamespace, + entity.kind, entity.metadata.name, ); @@ -63,9 +68,16 @@ export class LocalPublish implements PublisherBase { reject(err); } - resolve({ - remoteUrl: `http://localhost:7000/api/techdocs/static/docs/${entity.metadata.name}`, - }); + this.discovery + .getBaseUrl('techdocs') + .then(techdocsApiUrl => { + resolve({ + remoteUrl: `${techdocsApiUrl}/static/docs/${entity.metadata.name}`, + }); + }) + .catch(reason => { + reject(reason); + }); }); }); } diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md new file mode 100644 index 0000000000..bd3ae21ab8 --- /dev/null +++ b/plugins/techdocs/CHANGELOG.md @@ -0,0 +1,95 @@ +# @backstage/plugin-techdocs + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] +- Updated dependencies [8b7737d0b] + - @backstage/core@0.3.1 + - @backstage/plugin-catalog@0.2.2 + - @backstage/test-utils@0.1.3 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [c5bab94ab] +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] +- Updated dependencies [2d0bd1be7] + - @backstage/core-api@0.2.1 + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + - @backstage/plugin-catalog@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI +- 8351ad79b: Add a message if techdocs takes long time to load + + Fixes #2416. + + The UI after the change should look like this: + + ![techdocs-progress-bar](https://user-images.githubusercontent.com/33940798/94189286-296ac980-fec8-11ea-9051-1b3db938d12f.gif) + +### Patch Changes + +- 782f3b354: add test case for Progress component +- 57b54c8ed: While techdocs fetches site name and metadata for the component, the page title was displayed as '[object Object] | Backstage'. This has now been fixed to display the component ID if site name is not present or being fetched. +- Updated dependencies [28edd7d29] +- Updated dependencies [819a70229] +- Updated dependencies [3a4236570] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [cbbd271c4] +- Updated dependencies [482b6313d] +- Updated dependencies [e0be86b6f] +- Updated dependencies [f70a52868] +- Updated dependencies [12b5fe940] +- Updated dependencies [368fd8243] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [a768a07fb] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [5adfc005e] +- Updated dependencies [f0aa01bcc] +- Updated dependencies [0aecfded0] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [8b9c8196f] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [60d40892c] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [2ebcfac8d] +- Updated dependencies [fa56f4615] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [b3d57961c] +- Updated dependencies [0b956f21b] +- Updated dependencies [26e69ab1a] +- Updated dependencies [97c2cb19b] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [cbab5bbf8] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/plugin-catalog@0.2.0 + - @backstage/core-api@0.2.0 + - @backstage/core@0.2.0 + - @backstage/catalog-model@0.2.0 + - @backstage/theme@0.2.0 + - @backstage/test-utils@0.1.2 diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 27b0b0b881..35b196e124 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.1.1-alpha.24", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -12,7 +12,6 @@ }, "scripts": { "build": "backstage-cli plugin:build", - "export": "backstage-cli plugin:export", "start": "backstage-cli plugin:serve", "lint": "backstage-cli lint", "test": "backstage-cli test", @@ -22,12 +21,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.24", - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/core-api": "^0.1.1-alpha.24", - "@backstage/plugin-catalog": "^0.1.1-alpha.24", - "@backstage/test-utils": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.2.0", + "@backstage/core": "^0.3.1", + "@backstage/core-api": "^0.2.1", + "@backstage/plugin-catalog": "^0.2.2", + "@backstage/test-utils": "^0.1.3", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -40,19 +39,41 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "canvas": "^2.6.1", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" - ] + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/techdocs", + "type": "object", + "properties": { + "techdocs": { + "type": "object", + "properties": { + "requestUrl": { + "type": "string", + "visibility": "frontend" + }, + "storageUrl": { + "type": "string" + } + }, + "required": [ + "requestUrl" + ] + } + } + } } diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index d10c7a5932..544c2cdadd 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -23,7 +23,7 @@ export const EntityPageDocs = ({ entity }: { entity: Entity }) => { diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 6dc0dd02ad..262f542ee6 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -17,8 +17,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { Route, Routes } from 'react-router-dom'; -import { WarningPanel } from '@backstage/core'; - +import { MissingAnnotationEmptyState } from '@backstage/core'; import { rootRouteRef, rootDocsRouteRef, @@ -43,15 +42,7 @@ export const EmbeddedDocsRouter = ({ entity }: { entity: Entity }) => { const projectId = entity.metadata.annotations?.[TECHDOCS_ANNOTATION]; if (!projectId) { - return ( - -
{TECHDOCS_ANNOTATION}
annotation is missing on the entity. -
- - Getting Started - -
- ); + return ; } return ( diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts index f7c52f8a7d..132976e280 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/api.test.ts @@ -28,7 +28,7 @@ describe('TechDocsStorageApi', () => { const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test.js`, + `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, ); }); @@ -36,7 +36,7 @@ describe('TechDocsStorageApi', () => { const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test/`, + `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, ); }); }); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 87a0a56a01..31e4a10e4c 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -51,9 +51,7 @@ export class TechDocsApi implements TechDocs { async getMetadata(metadataType: string, entityId: ParsedEntityId) { const { kind, namespace, name } = entityId; - const requestUrl = `${this.apiOrigin}/metadata/${metadataType}/${kind}/${ - namespace ? namespace : 'default' - }/${name}`; + const requestUrl = `${this.apiOrigin}/metadata/${metadataType}/${namespace}/${kind}/${name}`; const request = await fetch(`${requestUrl}`); const res = await request.json(); @@ -72,9 +70,7 @@ export class TechDocsStorageApi implements TechDocsStorage { async getEntityDocs(entityId: ParsedEntityId, path: string) { const { kind, namespace, name } = entityId; - const url = `${this.apiOrigin}/docs/${kind}/${ - namespace ? namespace : 'default' - }/${name}/${path}`; + const url = `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, @@ -96,9 +92,7 @@ export class TechDocsStorageApi implements TechDocsStorage { return new URL( oldBaseUrl, - `${this.apiOrigin}/docs/${kind}/${ - namespace ? namespace : 'default' - }/${name}/${path}`, + `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`, ).toString(); } } diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index b197bdfb76..ee2cab60fa 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -48,7 +48,7 @@ export const rootRouteRef = createRouteRef({ }); export const rootDocsRouteRef = createRouteRef({ - path: ':entityId/*', + path: ':namespace/:kind/:name/*', title: 'Docs', }); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 550b8df923..785c6f1c6b 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -30,6 +30,7 @@ import transformer, { rewriteDocLinks, addLinkClickListener, removeMkdocsHeader, + simplifyMkdocsFooter, modifyCss, onCssReady, sanitizeDOM, @@ -81,6 +82,7 @@ export const Reader = ({ entityId, onReady }: Props) => { }, }), removeMkdocsHeader(), + simplifyMkdocsFooter(), injectCss({ css: ` body { @@ -116,6 +118,7 @@ export const Reader = ({ entityId, onReady }: Props) => { return dom; }, addLinkClickListener({ + baseUrl: window.location.origin, onClick: (_: MouseEvent, url: string) => { const parsedUrl = new URL(url); navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx index 7754f227fc..0924b90fc1 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx @@ -13,18 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TechDocsHome } from './TechDocsHome'; -import React from 'react'; -import { render } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { ApiRegistry, ApiProvider } from '@backstage/core-api'; -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core-api'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { TechDocsHome } from './TechDocsHome'; describe('TechDocs Home', () => { const catalogApi: Partial = { - getEntities: () => Promise.resolve([] as Entity[]), + getEntities: () => Promise.resolve({ items: [] }), }; const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]); diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index c478a52127..9f569dd167 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -14,20 +14,19 @@ * limitations under the License. */ -import React from 'react'; -import { useAsync } from 'react-use'; -import { useNavigate, generatePath } from 'react-router-dom'; -import { Grid } from '@material-ui/core'; import { + Content, + Header, ItemCard, + Page, Progress, useApi, - Content, - Page, - pageTheme, - Header, } from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { generatePath, useNavigate } from 'react-router-dom'; +import { useAsync } from 'react-use'; import { rootDocsRouteRef } from '../../plugin'; export const TechDocsHome = () => { @@ -35,15 +34,15 @@ export const TechDocsHome = () => { const navigate = useNavigate(); const { value, loading, error } = useAsync(async () => { - const entities = await catalogApi.getEntities(); - return entities.filter(entity => { + const response = await catalogApi.getEntities(); + return response.items.filter(entity => { return !!entity.metadata.annotations?.['backstage.io/techdocs-ref']; }); }); if (loading) { return ( - +
{ if (error) { return ( - +
{ } return ( - +
{ onClick={() => navigate( generatePath(rootDocsRouteRef.path, { - entityId: `${entity.kind}:${ - entity.metadata.namespace ?? '' - }:${entity.metadata.name}`, + namespace: entity.metadata.namespace ?? 'default', + kind: entity.kind, + name: entity.metadata.name, }), ) } diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.tsx index a70ce76191..00f0b3c9d3 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.tsx @@ -16,7 +16,7 @@ import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Content, Page, pageTheme, useApi } from '@backstage/core'; +import { Content, Page, useApi } from '@backstage/core'; import { Reader } from './Reader'; import { useAsync } from 'react-use'; import { TechDocsPageHeader } from './TechDocsPageHeader'; @@ -24,8 +24,7 @@ import { techdocsApiRef } from '../../api'; export const TechDocsPage = () => { const [documentReady, setDocumentReady] = useState(false); - const { entityId } = useParams(); - const [kind, namespace, name] = entityId.split(':'); + const { namespace, kind, name } = useParams(); const techDocsApi = useApi(techdocsApiRef); @@ -46,7 +45,7 @@ export const TechDocsPage = () => { }; return ( - + ', () => { ), ); expect(rendered.container.innerHTML).toContain('header'); - expect(rendered.getByText('test-site-name')).toBeDefined(); + expect(rendered.getAllByText('test-site-name')).toHaveLength(2); expect(rendered.getByText('test-site-desc')).toBeDefined(); }); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx index 8084378ca1..f4c447bae2 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import GitHubIcon from '@material-ui/icons/GitHub'; +import CodeIcon from '@material-ui/icons/Code'; import { Header, HeaderLabel, Link } from '@backstage/core'; import { CircularProgress } from '@material-ui/core'; import { ParsedEntityId } from '../../types'; @@ -48,12 +48,14 @@ export const TechDocsPageHeader = ({ spec: { owner, lifecycle }, } = entityMetadataValues || { spec: {} }; + const componentLink = `/catalog/${kind}/${name}`; + const labels = ( <> + {name} } @@ -71,7 +73,7 @@ export const TechDocsPageHeader = ({ target="_blank" rel="noopener noreferrer" > - + } /> @@ -82,9 +84,12 @@ export const TechDocsPageHeader = ({ return (
} + pageTitleOverride={siteName || name} subtitle={ siteDescription && siteDescription !== 'None' ? siteDescription : '' } + type={name} + typeLink={componentLink} > {labels}
diff --git a/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx b/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx index be464bd485..f97fcda3f1 100644 --- a/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsProgressBar.test.tsx @@ -25,12 +25,14 @@ jest.useFakeTimers(); describe('', () => { it('should render a message if techdocs page takes more time to load', () => { const rendered = render(wrapInTestApp()); - - expect(rendered.getByTestId('progress')).toBeDefined(); + act(() => { + jest.advanceTimersByTime(250); + }); + expect(rendered.getByTestId('progress')).toBeInTheDocument(); expect(rendered.queryByTestId('delay-reason')).toBeNull(); act(() => { jest.advanceTimersByTime(5000); }); - expect(rendered.getByTestId('delay-reason')).toBeDefined(); + expect(rendered.getByTestId('delay-reason')).toBeInTheDocument(); }); }); diff --git a/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts index 8e942b8be1..775780fe4d 100644 --- a/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts +++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts @@ -14,23 +14,61 @@ * limitations under the License. */ -import { createTestShadowDom, FIXTURES } from '../../test-utils'; +import { createTestShadowDom } from '../../test-utils'; import { addLinkClickListener } from '.'; describe('addLinkClickListener', () => { it('calls onClick when a link has been clicked', () => { const fn = jest.fn(); - const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { - preTransformers: [], - postTransformers: [ - addLinkClickListener({ - onClick: fn, - }), - ], - }); + const shadowDom = createTestShadowDom( + ` + + + + Link + + + `, + { + preTransformers: [], + postTransformers: [ + addLinkClickListener({ + baseUrl: 'http://localhost:3000', + onClick: fn, + }), + ], + }, + ); shadowDom.querySelector('a')?.click(); expect(fn).toHaveBeenCalledTimes(1); }); + + it('does not call onClick when a link links to another baseUrl', () => { + const fn = jest.fn(); + const shadowDom = createTestShadowDom( + ` + + + + Link + + + `, + { + preTransformers: [], + postTransformers: [ + addLinkClickListener({ + baseUrl: 'http://localhost:3000', + onClick: fn, + }), + ], + }, + ); + + shadowDom.querySelector('a')?.click(); + + expect(fn).toHaveBeenCalledTimes(0); + }); }); diff --git a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts index 3566b403a2..b6a6509550 100644 --- a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts +++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts @@ -17,10 +17,12 @@ import type { Transformer } from './index'; type AddLinkClickListenerOptions = { + baseUrl: string; onClick: (e: MouseEvent, newUrl: string) => void; }; export const addLinkClickListener = ({ + baseUrl, onClick, }: AddLinkClickListenerOptions): Transformer => { return dom => { @@ -28,8 +30,9 @@ export const addLinkClickListener = ({ elem.addEventListener('click', (e: MouseEvent) => { const target = e.target as HTMLAnchorElement; const href = target?.getAttribute('href'); + if (!href) return; - if (!href.match(/^https?:\/\//i)) { + if (href.startsWith(baseUrl)) { e.preventDefault(); onClick(e, target.getAttribute('href')!); } diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index ddbaadd071..0bab085abe 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -18,6 +18,7 @@ export * from './addBaseUrl'; export * from './rewriteDocLinks'; export * from './addLinkClickListener'; export * from './removeMkdocsHeader'; +export * from './simplifyMkdocsFooter'; export * from './modifyCss'; export * from './onCssReady'; export * from './sanitizeDOM'; diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts index c83a94f35d..67ae38c56b 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts @@ -25,14 +25,20 @@ import { onCssReady } from '../transformers'; const docStorageUrl: string = 'https://techdocs-mock-sites.storage.googleapis.com'; -jest.useFakeTimers(); - const fixture = ` `; describe('onCssReady', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + beforeEach(() => { mockStylesheetEventListener(100); }); diff --git a/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.test.ts b/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.test.ts new file mode 100644 index 0000000000..5af62a43c5 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.test.ts @@ -0,0 +1,38 @@ +/* + * 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 { createTestShadowDom, FIXTURES } from '../../test-utils'; +import { simplifyMkdocsFooter } from '.'; + +describe('simplifyMkdocsFooter', () => { + it('does not remove mkdocs copyright', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + preTransformers: [], + postTransformers: [], + }); + + expect(shadowDom.querySelector('.md-footer-copyright')).toBeTruthy(); + }); + + it('does remove mkdocs copyright', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + preTransformers: [simplifyMkdocsFooter()], + postTransformers: [], + }); + + expect(shadowDom.querySelector('.md-footer-copyright')).toBeFalsy(); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.ts b/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.ts new file mode 100644 index 0000000000..0d0745724d --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.ts @@ -0,0 +1,26 @@ +/* + * 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 type { Transformer } from './index'; + +export const simplifyMkdocsFooter = (): Transformer => { + return dom => { + // Remove mkdocs copyright + dom.querySelector('.md-footer-copyright')?.remove(); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/setupTests.ts b/plugins/techdocs/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/techdocs/src/setupTests.ts +++ b/plugins/techdocs/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/user-settings/.eslintrc.js b/plugins/user-settings/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/user-settings/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/user-settings/CHANGELOG.md b/plugins/user-settings/CHANGELOG.md new file mode 100644 index 0000000000..9efbc6eba7 --- /dev/null +++ b/plugins/user-settings/CHANGELOG.md @@ -0,0 +1,56 @@ +# @backstage/plugin-user-settings + +## 0.2.2 + +### Patch Changes + +- 1722cb53c: Added configuration schema +- Updated dependencies [1722cb53c] + - @backstage/core@0.3.1 + +## 0.2.1 + +### Patch Changes + +- 5a2705de2: Export `AuthProviders`, `DefaultProviderSettings` and `ProviderSettingsItem`. +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 4fc1d440e: Add settings button to sidebar + +### Patch Changes + +- Updated dependencies [819a70229] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/core@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/user-settings/README.md b/plugins/user-settings/README.md new file mode 100644 index 0000000000..db0b01681f --- /dev/null +++ b/plugins/user-settings/README.md @@ -0,0 +1,65 @@ +# user-settings + +Welcome to the user-settings plugin! + +_This plugin was created through the Backstage CLI_ + +## About the plugin + +This plugin provides two components, `` is intended to be used within the [``](https://backstage.io/storybook/?path=/story/sidebar--sample-sidebar) and displays the signed-in users profile picture and name. + +The second component is a settings page where the user can control different settings across the App. + +## Usage + +Add the item to the Sidebar: + +```ts +import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; + + + + + +; +``` + +Add the page to the App routing: + +```ts +import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; + +const AppRoutes = () => ( + + } /> + +); +``` + +### Props + +**Auth Providers** + +By default, the plugin provides a list of configured authentication providers fetched from `app-config.yaml` and displayed in the "Authentication Providers" tab. + +If you want to supply your own custom list of Authentication Providers, use the `providerSettings` prop: + +```ts +const MyAuthProviders = () => ( + + + {someAction} + +); + +const AppRoutes = () => ( + + } />} + /> + +); +``` + +> **Note that the list of providers expects to be rendered within a MUI [``](https://material-ui.com/components/lists/)** diff --git a/plugins/user-settings/dev/index.tsx b/plugins/user-settings/dev/index.tsx new file mode 100644 index 0000000000..264d6f801f --- /dev/null +++ b/plugins/user-settings/dev/index.tsx @@ -0,0 +1,19 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json new file mode 100644 index 0000000000..1277ae214a --- /dev/null +++ b/plugins/user-settings/package.json @@ -0,0 +1,68 @@ +{ + "name": "@backstage/plugin-user-settings", + "version": "0.2.2", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core": "^0.3.1", + "@backstage/theme": "^0.2.1", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ], + "configSchema": { + "$schema": "https://backstage.io/schema/config-v1", + "title": "@backstage/user-settings", + "type": "object", + "properties": { + "auth": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "additionalProperties": { + "type": "object", + "visibility": "frontend" + } + } + } + } + } + } +} diff --git a/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx b/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx new file mode 100644 index 0000000000..4399b6bd3e --- /dev/null +++ b/plugins/user-settings/src/components/AuthProviders/AuthProviders.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiProvider, + ApiRegistry, + configApiRef, + ConfigReader, + googleAuthApiRef, +} from '@backstage/core'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { AuthProviders } from './AuthProviders'; + +const mockSignInHandler = jest.fn().mockReturnValue(''); +const mockGoogleAuth = { + sessionState$: () => ({ + subscribe: () => ({ + unsubscribe: () => null, + }), + }), + signIn: mockSignInHandler, +}; + +const createConfig = () => + ConfigReader.fromConfigs([ + { + context: '', + data: { + auth: { + providers: { + google: { development: {} }, + }, + }, + }, + }, + ]); + +const config = createConfig(); + +const apiRegistry = ApiRegistry.from([ + [configApiRef, config], + [googleAuthApiRef, mockGoogleAuth], +]); + +describe('', () => { + it('displays a provider and calls its sign-in handler on click', async () => { + const rendered = await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect(rendered.getByText('Google')).toBeInTheDocument(); + expect( + rendered.getByText(googleAuthApiRef.description), + ).toBeInTheDocument(); + + const button = rendered.getByTitle('Sign in to Google'); + fireEvent.click(button); + expect(mockSignInHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx b/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx new file mode 100644 index 0000000000..bc2a68578d --- /dev/null +++ b/plugins/user-settings/src/components/AuthProviders/AuthProviders.tsx @@ -0,0 +1,44 @@ +/* + * 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 from 'react'; +import { List } from '@material-ui/core'; +import { configApiRef, InfoCard, useApi } from '@backstage/core'; +import { EmptyProviders } from './EmptyProviders'; +import { DefaultProviderSettings } from './DefaultProviderSettings'; + +type Props = { + providerSettings?: JSX.Element; +}; + +export const AuthProviders = ({ providerSettings }: Props) => { + const configApi = useApi(configApiRef); + const providersConfig = configApi.getOptionalConfig('auth.providers'); + const configuredProviders = providersConfig?.keys() || []; + const providers = providerSettings ?? ( + + ); + + if (!providerSettings && !configuredProviders?.length) { + return ; + } + + return ( + + {providers} + + ); +}; diff --git a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx new file mode 100644 index 0000000000..a1dab1e7d4 --- /dev/null +++ b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx @@ -0,0 +1,83 @@ +/* + * 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 { + githubAuthApiRef, + gitlabAuthApiRef, + googleAuthApiRef, + oauth2ApiRef, + oktaAuthApiRef, + microsoftAuthApiRef, +} from '@backstage/core'; +import Star from '@material-ui/icons/Star'; +import React from 'react'; +import { ProviderSettingsItem } from './ProviderSettingsItem'; + +type Props = { + configuredProviders: string[]; +}; + +export const DefaultProviderSettings = ({ configuredProviders }: Props) => ( + <> + {configuredProviders.includes('google') && ( + + )} + {configuredProviders.includes('microsoft') && ( + + )} + {configuredProviders.includes('github') && ( + + )} + {configuredProviders.includes('gitlab') && ( + + )} + {configuredProviders.includes('okta') && ( + + )} + {configuredProviders.includes('oauth2') && ( + + )} + +); diff --git a/plugins/user-settings/src/components/AuthProviders/EmptyProviders.tsx b/plugins/user-settings/src/components/AuthProviders/EmptyProviders.tsx new file mode 100644 index 0000000000..653f449e92 --- /dev/null +++ b/plugins/user-settings/src/components/AuthProviders/EmptyProviders.tsx @@ -0,0 +1,59 @@ +/* + * 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 from 'react'; +import { CodeSnippet, EmptyState } from '@backstage/core'; +import { Button, Typography } from '@material-ui/core'; + +const EXAMPLE = `auth: + providers: + google: + development: + clientId: + $env: AUTH_GOOGLE_CLIENT_ID + clientSecret: + $env: AUTH_GOOGLE_CLIENT_SECRET +`; + +export const EmptyProviders = () => ( + + + Open app-config.yaml and make the changes as highlighted + below: + + + + + } + /> +); diff --git a/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx similarity index 65% rename from packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx rename to plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx index 4db7ea0823..4bae3dbfbb 100644 --- a/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx @@ -13,36 +13,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import React, { FC, useState, useEffect } from 'react'; -import { - ListItem, - ListItemIcon, - ListItemSecondaryAction, - ListItemText, - Tooltip, -} from '@material-ui/core'; -import PowerButton from '@material-ui/icons/PowerSettingsNew'; -import { ToggleButton } from '@material-ui/lab'; +import React, { useEffect, useState } from 'react'; import { ApiRef, SessionApi, useApi, IconComponent, SessionState, -} from '@backstage/core-api'; +} from '@backstage/core'; +import { + Button, + ListItem, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + Tooltip, +} from '@material-ui/core'; -type OAuthProviderSidebarProps = { +type Props = { title: string; + description: string; icon: IconComponent; apiRef: ApiRef; }; -export const ProviderSettingsItem: FC = ({ +export const ProviderSettingsItem = ({ title, + description, icon: Icon, apiRef, -}) => { +}: Props) => { const api = useApi(apiRef); const [signedIn, setSignedIn] = useState(false); @@ -68,22 +68,29 @@ export const ProviderSettingsItem: FC = ({ - - - (signedIn ? api.signOut() : api.signIn())} - > - - + + {description} - + } + secondaryTypographyProps={{ noWrap: true, style: { width: '80%' } }} + /> + + + + ); diff --git a/packages/core/src/layout/Sidebar/Settings/index.ts b/plugins/user-settings/src/components/AuthProviders/index.ts similarity index 84% rename from packages/core/src/layout/Sidebar/Settings/index.ts rename to plugins/user-settings/src/components/AuthProviders/index.ts index 15042dc85d..dd7fbcf79b 100644 --- a/packages/core/src/layout/Sidebar/Settings/index.ts +++ b/plugins/user-settings/src/components/AuthProviders/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export { AuthProviders } from './AuthProviders'; +export { DefaultProviderSettings } from './DefaultProviderSettings'; export { ProviderSettingsItem } from './ProviderSettingsItem'; -export { SidebarUserSettings } from './UserSettings'; diff --git a/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx new file mode 100644 index 0000000000..5df4df4450 --- /dev/null +++ b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx @@ -0,0 +1,58 @@ +/* + * 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 from 'react'; +import { CodeSnippet, EmptyState } from '@backstage/core'; +import { Button, Typography } from '@material-ui/core'; + +const EXAMPLE = `import { createPlugin } from '@backstage/core'; + +export default createPlugin({ + id: 'welcome', + register({ router, featureFlags }) { + featureFlags.register('enable-example-feature'); + }, +}); +`; + +export const EmptyFlags = () => ( + + + An example how how to add a feature flags is highlighted below: + + + + + } + /> +); diff --git a/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx b/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx new file mode 100644 index 0000000000..0a9a22659c --- /dev/null +++ b/plugins/user-settings/src/components/FeatureFlags/FeatureFlags.tsx @@ -0,0 +1,79 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { + featureFlagsApiRef, + FeatureFlagState, + InfoCard, + useApi, +} from '@backstage/core'; +import { List } from '@material-ui/core'; +import { EmptyFlags } from './EmptyFlags'; +import { FlagItem } from './FeatureFlagsItem'; + +export const FeatureFlags = () => { + const featureFlagsApi = useApi(featureFlagsApiRef); + const featureFlags = featureFlagsApi.getRegisteredFlags(); + + const initialFlagState = Object.fromEntries( + featureFlags.map(({ name }) => [name, featureFlagsApi.isActive(name)]), + ); + + const [state, setState] = useState>(initialFlagState); + + const toggleFlag = useCallback( + (flagName: string) => { + const newState = featureFlagsApi.isActive(flagName) + ? FeatureFlagState.None + : FeatureFlagState.Active; + + featureFlagsApi.save({ + states: { [flagName]: newState }, + merge: true, + }); + + setState(prevState => ({ + ...prevState, + [flagName]: newState === FeatureFlagState.Active, + })); + }, + [featureFlagsApi], + ); + + if (!featureFlags.length) { + return ; + } + + return ( + + + {featureFlags.map(featureFlag => { + const enabled = Boolean(state[featureFlag.name]); + + return ( + + ); + })} + + + ); +}; diff --git a/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx b/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx new file mode 100644 index 0000000000..603f63ef73 --- /dev/null +++ b/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx @@ -0,0 +1,50 @@ +/* + * 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 from 'react'; +import { + ListItem, + ListItemSecondaryAction, + ListItemText, + Switch, + Tooltip, +} from '@material-ui/core'; +import { FeatureFlag } from '@backstage/core'; + +type Props = { + flag: FeatureFlag; + enabled: boolean; + toggleHandler: Function; +}; + +export const FlagItem = ({ flag, enabled, toggleHandler }: Props) => ( + + + + + toggleHandler(flag.name)} + name={flag.name} + /> + + + +); diff --git a/plugins/user-settings/src/components/FeatureFlags/index.ts b/plugins/user-settings/src/components/FeatureFlags/index.ts new file mode 100644 index 0000000000..37c9fd1fd2 --- /dev/null +++ b/plugins/user-settings/src/components/FeatureFlags/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { FeatureFlags } from './FeatureFlags'; diff --git a/plugins/user-settings/src/components/General/General.tsx b/plugins/user-settings/src/components/General/General.tsx new file mode 100644 index 0000000000..2d30c00eec --- /dev/null +++ b/plugins/user-settings/src/components/General/General.tsx @@ -0,0 +1,41 @@ +/* + * 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 { InfoCard } from '@backstage/core'; +import { Grid, List, useMediaQuery, useTheme } from '@material-ui/core'; +import React from 'react'; +import { PinButton } from './PinButton'; +import { Profile } from './Profile'; +import { ThemeToggle } from './ThemeToggle'; + +export const General = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + return ( + + + + + + + + + {!fullScreen && } + + + + + ); +}; diff --git a/plugins/user-settings/src/components/General/PinButton.test.tsx b/plugins/user-settings/src/components/General/PinButton.test.tsx new file mode 100644 index 0000000000..939588a9ee --- /dev/null +++ b/plugins/user-settings/src/components/General/PinButton.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 { SidebarPinStateContext } from '@backstage/core'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { PinButton } from './PinButton'; + +describe('', () => { + it('toggles the pin sidebar button', async () => { + const mockToggleFn = jest.fn(); + const rendered = await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + expect(rendered.getByText('Pin Sidebar')).toBeInTheDocument(); + + const pinButton = rendered.getByLabelText('Pin Sidebar Switch'); + fireEvent.click(pinButton); + expect(mockToggleFn).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/layout/Sidebar/Settings/PinButton.tsx b/plugins/user-settings/src/components/General/PinButton.tsx similarity index 61% rename from packages/core/src/layout/Sidebar/Settings/PinButton.tsx rename to plugins/user-settings/src/components/General/PinButton.tsx index 2727313ede..d44e5c9a3b 100644 --- a/packages/core/src/layout/Sidebar/Settings/PinButton.tsx +++ b/plugins/user-settings/src/components/General/PinButton.tsx @@ -19,28 +19,16 @@ import { ListItem, ListItemSecondaryAction, ListItemText, + Switch, Tooltip, } from '@material-ui/core'; -import LockIcon from '@material-ui/icons/Lock'; -import LockOpenIcon from '@material-ui/icons/LockOpen'; -import { ToggleButton } from '@material-ui/lab'; -import { SidebarPinStateContext } from '../Page'; +import { SidebarPinStateContext } from '@backstage/core'; -export const SidebarPinButton = () => { +export const PinButton = () => { const { isPinned, toggleSidebarPinState } = useContext( SidebarPinStateContext, ); - const PinIcon = () => ( - - {isPinned ? : } - - ); - return ( { secondary="Prevent the sidebar from collapsing" /> - { - toggleSidebarPinState(); - }} + - - + toggleSidebarPinState()} + name="pin" + inputProps={{ 'aria-label': 'Pin Sidebar Switch' }} + /> + ); diff --git a/plugins/user-settings/src/components/General/Profile.tsx b/plugins/user-settings/src/components/General/Profile.tsx new file mode 100644 index 0000000000..b0231d95b6 --- /dev/null +++ b/plugins/user-settings/src/components/General/Profile.tsx @@ -0,0 +1,54 @@ +/* + * 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 { InfoCard } from '@backstage/core'; +import { Grid, Typography } from '@material-ui/core'; +import React from 'react'; +import { SignInAvatar } from './SignInAvatar'; +import { UserSettingsMenu } from './UserSettingsMenu'; +import { useUserProfile } from '../useUserProfileInfo'; + +export const Profile = () => { + const { profile, displayName } = useUserProfile(); + + return ( + + + + + + + + + + + + {displayName} + + + {profile.email} + + + + + + + + + + + + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/SignInAvatar.tsx b/plugins/user-settings/src/components/General/SignInAvatar.tsx similarity index 72% rename from packages/core/src/layout/Sidebar/Settings/SignInAvatar.tsx rename to plugins/user-settings/src/components/General/SignInAvatar.tsx index f0430edfcf..8a5bbd75bf 100644 --- a/packages/core/src/layout/Sidebar/Settings/SignInAvatar.tsx +++ b/plugins/user-settings/src/components/General/SignInAvatar.tsx @@ -17,26 +17,24 @@ import React from 'react'; import { BackstageTheme } from '@backstage/theme'; import { makeStyles, Avatar } from '@material-ui/core'; -import { useUserProfile } from './useUserProfileInfo'; -import { sidebarConfig } from '../config'; +import { useUserProfile } from '../useUserProfileInfo'; +import { sidebarConfig } from '@backstage/core'; -const useStyles = makeStyles({ +const useStyles = makeStyles(theme => ({ avatar: { width: ({ size }) => size, height: ({ size }) => size, + fontSize: ({ size }) => size * 0.7, + border: `1px solid ${theme.palette.textSubtle}`, }, -}); +})); type Props = { size?: number }; export const SignInAvatar = ({ size }: Props) => { const { iconSize } = sidebarConfig; const classes = useStyles(size ? { size } : { size: iconSize }); - const { profile, displayName } = useUserProfile(); + const { profile } = useUserProfile(); - return ( - - {displayName[0]} - - ); + return ; }; diff --git a/plugins/user-settings/src/components/General/ThemeToggle.test.tsx b/plugins/user-settings/src/components/General/ThemeToggle.test.tsx new file mode 100644 index 0000000000..79d9210826 --- /dev/null +++ b/plugins/user-settings/src/components/General/ThemeToggle.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ApiProvider, + ApiRegistry, + appThemeApiRef, + AppThemeSelector, +} from '@backstage/core'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { lightTheme } from '@backstage/theme'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { ThemeToggle } from './ThemeToggle'; + +const mockTheme = { + id: 'light', + title: 'Mock Theme', + variant: 'light' as 'light', // wut? + theme: lightTheme, +}; + +const apiRegistry = ApiRegistry.from([ + [appThemeApiRef, AppThemeSelector.createWithStorage([mockTheme])], +]); + +describe('', () => { + it('toggles the theme select button', async () => { + const themeApi = apiRegistry.get(appThemeApiRef); + const rendered = await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect(rendered.getByText('Theme')).toBeInTheDocument(); + + const themeButton = rendered.getByTitle('Select Mock Theme'); + expect(themeApi?.getActiveThemeId()).toBe(undefined); + fireEvent.click(themeButton); + expect(themeApi?.getActiveThemeId()).toBe('light'); + }); +}); diff --git a/plugins/user-settings/src/components/General/ThemeToggle.tsx b/plugins/user-settings/src/components/General/ThemeToggle.tsx new file mode 100644 index 0000000000..e142b6bf1e --- /dev/null +++ b/plugins/user-settings/src/components/General/ThemeToggle.tsx @@ -0,0 +1,154 @@ +/* + * 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, { cloneElement } from 'react'; +import { useObservable } from 'react-use'; +import AutoIcon from '@material-ui/icons/BrightnessAuto'; +import { appThemeApiRef, useApi } from '@backstage/core'; +import ToggleButton from '@material-ui/lab/ToggleButton'; +import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'; +import { + ListItem, + ListItemText, + ListItemSecondaryAction, + Tooltip, + makeStyles, +} from '@material-ui/core'; + +type ThemeIconProps = { + id: string; + activeId: string | undefined; + icon: JSX.Element | undefined; +}; + +const ThemeIcon = ({ id, activeId, icon }: ThemeIconProps) => + icon ? ( + cloneElement(icon, { + color: activeId === id ? 'primary' : undefined, + }) + ) : ( + + ); + +type TooltipToggleButtonProps = { + children: JSX.Element; + title: string; + value: string; +}; + +const useStyles = makeStyles(theme => ({ + list: { + [theme.breakpoints.down('xs')]: { + padding: `0 0 12px`, + }, + }, + listItemText: { + [theme.breakpoints.down('xs')]: { + paddingRight: 0, + paddingLeft: 0, + }, + }, + listItemSecondaryAction: { + [theme.breakpoints.down('xs')]: { + width: '100%', + top: 'auto', + right: 'auto', + position: 'relative', + transform: 'unset', + }, + }, +})); + +// ToggleButtonGroup uses React.children.map instead of context +// so wrapping with Tooltip breaks ToggleButton functionality. +const TooltipToggleButton = ({ + children, + title, + value, + ...props +}: TooltipToggleButtonProps) => ( + + + {children} + + +); + +export const ThemeToggle = () => { + const classes = useStyles(); + const appThemeApi = useApi(appThemeApiRef); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + + const themeIds = appThemeApi.getInstalledThemes(); + + const handleSetTheme = ( + _event: React.MouseEvent, + newThemeId: string | undefined, + ) => { + if (themeIds.some(t => t.id === newThemeId)) { + appThemeApi.setActiveThemeId(newThemeId); + } else { + appThemeApi.setActiveThemeId(undefined); + } + }; + + return ( + + + + + {themeIds.map(theme => { + const themeIcon = themeIds.find(t => t.id === theme.id)?.icon; + return ( + + <> + {theme.variant}  + + + + ); + })} + + + Auto  + + + + + + + ); +}; diff --git a/plugins/user-settings/src/components/General/UserSettingsMenu.test.tsx b/plugins/user-settings/src/components/General/UserSettingsMenu.test.tsx new file mode 100644 index 0000000000..c86d702e30 --- /dev/null +++ b/plugins/user-settings/src/components/General/UserSettingsMenu.test.tsx @@ -0,0 +1,33 @@ +/* + * 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 { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { UserSettingsMenu } from './UserSettingsMenu'; + +describe('', () => { + it('displays a menu button with a sign-out option', async () => { + const rendered = await renderWithEffects( + wrapInTestApp(), + ); + + const menuButton = rendered.getByLabelText('more'); + fireEvent.click(menuButton); + + expect(rendered.getByText('Sign Out')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx b/plugins/user-settings/src/components/General/UserSettingsMenu.tsx similarity index 93% rename from packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx rename to plugins/user-settings/src/components/General/UserSettingsMenu.tsx index 151ddb6e75..19c3ee4e2d 100644 --- a/packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsMenu.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { identityApiRef, useApi } from '@backstage/core-api'; +import { identityApiRef, useApi } from '@backstage/core'; import { IconButton, ListItemIcon, Menu, MenuItem } from '@material-ui/core'; import SignOutIcon from '@material-ui/icons/MeetingRoom'; import MoreVertIcon from '@material-ui/icons/MoreVert'; @@ -39,7 +39,7 @@ export const UserSettingsMenu = () => { return ( <> - + diff --git a/packages/app/src/plugins/cost-insights/index.ts b/plugins/user-settings/src/components/General/index.ts similarity index 87% rename from packages/app/src/plugins/cost-insights/index.ts rename to plugins/user-settings/src/components/General/index.ts index 47d540049f..2015d345fe 100644 --- a/packages/app/src/plugins/cost-insights/index.ts +++ b/plugins/user-settings/src/components/General/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export { ExampleCostInsightsClient } from './ExampleCostInsightsClient'; +export { General } from './General'; +export { SignInAvatar } from './SignInAvatar'; diff --git a/packages/core/src/layout/Sidebar/Settings/AppSettingsList.tsx b/plugins/user-settings/src/components/Settings.tsx similarity index 65% rename from packages/core/src/layout/Sidebar/Settings/AppSettingsList.tsx rename to plugins/user-settings/src/components/Settings.tsx index 5dad178ccd..25559299a8 100644 --- a/packages/core/src/layout/Sidebar/Settings/AppSettingsList.tsx +++ b/plugins/user-settings/src/components/Settings.tsx @@ -13,14 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { List, ListSubheader } from '@material-ui/core'; -import { SidebarThemeToggle } from './ThemeToggle'; -import { SidebarPinButton } from './PinButton'; -export const AppSettingsList = () => ( - App Settings}> - - - -); +import React from 'react'; +import { SidebarItem } from '@backstage/core'; +import SettingsIcon from '@material-ui/icons/Settings'; +import { settingsRouteRef } from '../plugin'; + +export const Settings = () => { + return ( + + ); +}; diff --git a/plugins/user-settings/src/components/SettingsPage.tsx b/plugins/user-settings/src/components/SettingsPage.tsx new file mode 100644 index 0000000000..0b4a86c99d --- /dev/null +++ b/plugins/user-settings/src/components/SettingsPage.tsx @@ -0,0 +1,52 @@ +/* + * 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, { useState } from 'react'; +import { Content, Header, HeaderTabs, Page } from '@backstage/core'; +import { General } from './General'; +import { AuthProviders } from './AuthProviders'; +import { FeatureFlags } from './FeatureFlags'; + +type Props = { + providerSettings?: JSX.Element; +}; + +export const SettingsPage = ({ providerSettings }: Props) => { + const [activeTab, setActiveTab] = useState(0); + const onTabChange = (index: number) => { + setActiveTab(index); + }; + + const tabs = [ + { id: 'general', label: 'General' }, + { id: 'auth-providers', label: 'Authentication Providers' }, + { id: 'feature-flags', label: 'Feature Flags' }, + ]; + + const content = [ + , + , + , + ]; + + return ( + +
+ + {content[activeTab]} + + ); +}; diff --git a/plugins/user-settings/src/components/index.ts b/plugins/user-settings/src/components/index.ts new file mode 100644 index 0000000000..fe92b14adb --- /dev/null +++ b/plugins/user-settings/src/components/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { Settings } from './Settings'; +export { SettingsPage as Router } from './SettingsPage'; +export * from './AuthProviders'; diff --git a/packages/core/src/layout/Sidebar/Settings/useUserProfileInfo.ts b/plugins/user-settings/src/components/useUserProfileInfo.ts similarity index 93% rename from packages/core/src/layout/Sidebar/Settings/useUserProfileInfo.ts rename to plugins/user-settings/src/components/useUserProfileInfo.ts index 60dae294a5..428719160d 100644 --- a/packages/core/src/layout/Sidebar/Settings/useUserProfileInfo.ts +++ b/plugins/user-settings/src/components/useUserProfileInfo.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useApi, identityApiRef } from '@backstage/core-api'; +import { useApi, identityApiRef } from '@backstage/core'; export const useUserProfile = () => { const identityApi = useApi(identityApiRef); diff --git a/plugins/user-settings/src/index.ts b/plugins/user-settings/src/index.ts new file mode 100644 index 0000000000..9b0ce4262c --- /dev/null +++ b/plugins/user-settings/src/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { plugin } from './plugin'; +export * from './components/'; diff --git a/plugins/user-settings/src/plugin.test.ts b/plugins/user-settings/src/plugin.test.ts new file mode 100644 index 0000000000..810a2cda12 --- /dev/null +++ b/plugins/user-settings/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * 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 { plugin } from './plugin'; + +describe('user-settings', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/user-settings/src/plugin.ts b/plugins/user-settings/src/plugin.ts new file mode 100644 index 0000000000..2f896c8197 --- /dev/null +++ b/plugins/user-settings/src/plugin.ts @@ -0,0 +1,25 @@ +/* + * 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 { createPlugin, createRouteRef } from '@backstage/core'; + +export const settingsRouteRef = createRouteRef({ + path: '/settings', + title: 'Settings', +}); + +export const plugin = createPlugin({ + id: 'user-settings', +}); diff --git a/plugins/user-settings/src/setupTests.ts b/plugins/user-settings/src/setupTests.ts new file mode 100644 index 0000000000..0bfa67b49a --- /dev/null +++ b/plugins/user-settings/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * 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 '@testing-library/jest-dom'; diff --git a/plugins/welcome/CHANGELOG.md b/plugins/welcome/CHANGELOG.md new file mode 100644 index 0000000000..e6165ef4f9 --- /dev/null +++ b/plugins/welcome/CHANGELOG.md @@ -0,0 +1,47 @@ +# @backstage/plugin-welcome + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [7b37d65fd] +- Updated dependencies [4aca74e08] +- Updated dependencies [e8f69ba93] +- Updated dependencies [0c0798f08] +- Updated dependencies [0c0798f08] +- Updated dependencies [199237d2f] +- Updated dependencies [6627b626f] +- Updated dependencies [4577e377b] + - @backstage/core@0.3.0 + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 28edd7d29: Create backend plugin through CLI + +### Patch Changes + +- Updated dependencies [819a70229] +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] +- Updated dependencies [482b6313d] +- Updated dependencies [1c60f716e] +- Updated dependencies [144c66d50] +- Updated dependencies [b79017fd3] +- Updated dependencies [6d97d2d6f] +- Updated dependencies [93a3fa3ae] +- Updated dependencies [782f3b354] +- Updated dependencies [2713f28f4] +- Updated dependencies [406015b0d] +- Updated dependencies [82759d3e4] +- Updated dependencies [ac8d5d5c7] +- Updated dependencies [ebca83d48] +- Updated dependencies [aca79334f] +- Updated dependencies [c0d5242a0] +- Updated dependencies [3beb5c9fc] +- Updated dependencies [754e31db5] +- Updated dependencies [1611c6dbc] + - @backstage/core@0.2.0 + - @backstage/theme@0.2.0 diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 80f930bbc2..47b02f971c 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.1.1-alpha.24", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -21,8 +21,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.24", - "@backstage/theme": "^0.1.1-alpha.24", + "@backstage/core": "^0.3.1", + "@backstage/theme": "^0.2.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -32,16 +32,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.24", - "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/cli": "^0.3.0", + "@backstage/dev-utils": "^0.1.4", + "@backstage/test-utils": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index 1afdb53ad4..4cd2c34ec1 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -30,7 +30,6 @@ import { Header, HomepageTimer, Page, - pageTheme, ContentHeader, SupportButton, WarningPanel, @@ -44,7 +43,7 @@ const WelcomePage = () => { const profile = { givenName: '' }; return ( - +
{ of these phases. The best way to keep track of the progress is through the  Milestones @@ -117,7 +116,7 @@ const WelcomePage = () => { We suggest you either check out the documentation for{' '} creating a plugin @@ -128,7 +127,7 @@ const WelcomePage = () => { {' '} in the directory{' '} plugins/ @@ -145,7 +144,7 @@ const WelcomePage = () => { Create a plugin diff --git a/scripts/check-docs-quality.js b/scripts/check-docs-quality.js index 61968a2ac0..3c05b406da 100755 --- a/scripts/check-docs-quality.js +++ b/scripts/check-docs-quality.js @@ -1,4 +1,3 @@ -#!/usr/bin/env node /* * Copyright 2020 Spotify AB * @@ -15,54 +14,79 @@ * limitations under the License. */ const { execSync, spawnSync } = require('child_process'); -const path = require('path'); - -const listFilesTrackedByGit = 'git ls-files'; +// eslint-disable-next-line import/no-extraneous-dependencies +const commandExists = require('command-exists'); const inheritStdIo = { stdio: 'inherit', }; -const ERROR_MESSAGE = - 'Please install vale linter(https://docs.errata.ai/vale/install). Ignore this message if already installed.\n'; +const LINT_SKIPPED_MESSAGE = + 'Skipping documentation quality check (vale not found). Install vale linter (https://docs.errata.ai/vale/install) to enable.\n'; +const LINT_ERROR_MESSAGE = `Language linter (vale) generated errors. Please check the errors and review any markdown files that you changed. + Possibly update .github/styles/vocab.txt to add new valid words.\n`; +const VALE_NOT_FOUND_MESSAGE = `Language linter (vale) was not found. Please install vale linter (https://docs.errata.ai/vale/install).\n`; -// xargs is not supported by shx. -if (process.platform === 'win32') { - const validMDFilesCommand = `${listFilesTrackedByGit} | .\\node_modules\\.bin\\shx grep ".md"`; - try { - // get list of all md files except in directories of gitignore. - let filesToLint = execSync(validMDFilesCommand, { - stdio: ['ignore', 'pipe', 'inherit'], +// Note: Make sure the script is run as `node check-docs-quality.js [FILES]` instead of `./check-docs-quality.js [FILES]` +// If the script receives arguments (file paths), the script is run exclusively on them. (e.g. when run via pre-commit hook) +const getFilesToLint = () => { + // Files have been provided as arguments + if (process.argv.length > 2) { + return process.argv.slice(2); + } + + let command = `git ls-files | ./node_modules/.bin/shx grep ".md"`; + if (process.platform === 'win32') { + command = `git ls-files | .\\node_modules\\.bin\\shx grep ".md"`; + } + + return execSync(command, { + stdio: ['ignore', 'pipe', 'inherit'], + }) + .toString() + .split('\n') + .filter(function (el) { + return el != ''; }); +}; - // set all file(s) path as absolute path - filesToLint = filesToLint - .toString() - .split('\n') - .map(filepath => (filepath ? path.join(process.cwd(), filepath) : null)) - .filter(Boolean); - - const output = spawnSync('vale', filesToLint, inheritStdIo); - - // if the command does not succeed - if (output.status !== 0) { - // if it contains system level error. [in this case vale does not exist] - if (output.error) { - console.error(ERROR_MESSAGE); - } +// Proceed with the script only if Vale linter is installed. Limit the friction and surprises caused by the script. +// On CI, we want to ensure vale linter is run. +commandExists('vale') + .catch(() => { + if (process.env.CI) { + console.log(VALE_NOT_FOUND_MESSAGE); process.exit(1); } - } catch (e) { - console.error(e.message); - process.exit(1); - } -} else { - const validMDFilesCommand = `${listFilesTrackedByGit} | ./node_modules/.bin/shx grep ".md"`; - // use xargs - try { - execSync(`${validMDFilesCommand} | xargs vale`, inheritStdIo); - } catch (e) { - console.error(ERROR_MESSAGE); - process.exit(1); - } -} + console.log(LINT_SKIPPED_MESSAGE); + process.exit(0); + }) + .then(() => { + const filesToLint = getFilesToLint(); + + if (process.platform === 'win32') { + // Windows + try { + const output = spawnSync('vale', filesToLint, inheritStdIo); + + // If the command does not succeed + if (output.status !== 0) { + // If it contains system level error. In this case vale does not exist. + if (output.error) { + console.log(LINT_ERROR_MESSAGE); + } + process.exit(1); + } + } catch (e) { + console.log(e.message); + process.exit(1); + } + } else { + // Unix + const output = spawnSync('vale', filesToLint, inheritStdIo); + if (output.status !== 0) { + console.log(LINT_ERROR_MESSAGE); + process.exit(1); + } + } + }); diff --git a/scripts/verify-links.js b/scripts/verify-links.js new file mode 100755 index 0000000000..0422352d4e --- /dev/null +++ b/scripts/verify-links.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +/* + * 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. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +const { resolve: resolvePath, join: joinPath, dirname } = require('path'); +const fs = require('fs-extra'); +const recursive = require('recursive-readdir'); + +const projectRoot = resolvePath(__dirname, '..'); + +async function verifyUrl(basePath, absUrl, docPages) { + // Avoid having absolute URL links within docs/, so that links work on the site + if ( + absUrl.match( + /https:\/\/github.com\/backstage\/backstage\/(tree|blob)\/master\/docs\//, + ) && + basePath.match(/^(?:docs|microsite)\//) + ) { + return { url: absUrl, basePath, problem: 'github' }; + } + + const url = absUrl + .replace(/#.*$/, '') + .replace( + /https:\/\/github.com\/backstage\/backstage\/(tree|blob)\/master/, + '', + ); + if (!url) { + return undefined; + } + + // Only verify existence of local files for now, so skip anything with a schema + if (url.match(/[a-z]+:/)) { + return undefined; + } + + let path = ''; + + if (url.startsWith('/')) { + if (url.startsWith('/docs/')) { + if (basePath.match(/^(?:docs)\//)) { + return { url, basePath, problem: 'not-relative' }; + } + if (basePath.startsWith('microsite/')) { + if (docPages.has(url)) { + return undefined; + } + return { url, basePath, problem: 'doc-missing' }; + } + } + + const staticPath = resolvePath(projectRoot, 'microsite/static', `.${url}`); + if (await fs.pathExists(staticPath)) { + return undefined; + } + + path = resolvePath(projectRoot, `.${url}`); + } else { + path = resolvePath(dirname(resolvePath(projectRoot, basePath)), url); + } + + const exists = await fs.pathExists(path); + if (!exists) { + return { url, basePath, problem: 'missing' }; + } + + return undefined; +} + +async function verifyFile(filePath, docPages) { + const content = await fs.readFile(filePath, 'utf8'); + const mdLinks = content.match(/\[.+?\]\(.+?\)/g) || []; + const badUrls = []; + + for (const mdLink of mdLinks) { + const url = mdLink.match(/\[.+\]\((.+)\)/)[1].trim(); + const badUrl = await verifyUrl(filePath, url, docPages); + if (badUrl) { + badUrls.push(badUrl); + } + } + + return badUrls; +} + +// This discovers the doc paths as they will be available on the microsite. +// It is used to validate microsite links from outside /docs/, as those +// are not transformed from the markdown file representation by docusaurus. +async function findExternalDocsLinks(dir) { + const allFiles = await recursive(dir); + const mdFiles = allFiles.filter(p => p.endsWith('.md')); + + const paths = new Map(); + + for (const file of mdFiles) { + const content = await fs.readFile(file, 'utf8'); + const url = `/${file}`; + const match = content.match(/---(?:\r|\n|.)*^id: (.*)$/m); + + // Both docs with an id and without should remove trailing /index + const realPath = (match + ? joinPath(dirname(url), match[1]) + : url.replace(/\.md$/, '') + ).replace(/\/index$/, ''); + + paths.set(url, realPath); + if (url.endsWith('/index.md')) { + paths.set(url.replace(/\/index\.md$/, ''), realPath); + } + } + + return paths; +} + +async function main() { + process.chdir(projectRoot); + + const files = await recursive('.', ['node_modules', 'dist', 'bin']); + const mdFiles = files.filter(f => f.endsWith('.md')); + const badUrls = []; + + const docPages = await findExternalDocsLinks('docs'); + + for (const mdFile of mdFiles) { + const badFileUrls = await verifyFile(mdFile, new Set(docPages.values())); + badUrls.push(...badFileUrls); + } + + if (badUrls.length) { + console.log(`Found ${badUrls.length} bad links within repo`); + for (const { url, basePath, problem } of badUrls) { + if (problem === 'missing') { + console.error( + `Unable to reach ${url} from root or microsite/static/, linked from ${basePath}`, + ); + } else if (problem === 'doc-missing') { + const suggestion = + docPages.get(url) || + docPages.get(new URL(url, 'http://localhost').pathname); + console.error('Links into /docs/ must use an externally reachable ID'); + console.error(` From: ${basePath}`); + console.error(` To: ${url}`); + if (suggestion) { + console.error(` Replace With: ${suggestion}`); + } + } else if (problem === 'not-relative') { + console.error('Links within /docs/ must be relative'); + console.error(` From: ${basePath}`); + console.error(` To: ${url}`); + } else if (problem === 'github') { + console.error( + `Link to docs/ should not use a GitHub URL, use a relative URL instead`, + ); + console.error(` From: ${basePath}`); + console.error(` To: ${url}`); + } + } + process.exit(1); + } +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/yarn.lock b/yarn.lock index 8b9b16ebe7..de6ec42c7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@apidevtools/json-schema-ref-parser@9.0.6", "@apidevtools/json-schema-ref-parser@^9.0.6": +"@apidevtools/json-schema-ref-parser@^9.0.6": version "9.0.6" resolved "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz#5d9000a3ac1fd25404da886da6b266adcd99cf1c" integrity sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg== @@ -56,10 +56,10 @@ dependencies: tslib "~2.0.1" -"@asyncapi/avro-schema-parser@^0.1.2": - version "0.1.2" - resolved "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-0.1.2.tgz#f6c340ccaa24bc36399d3a0f1a6e02790a948453" - integrity sha512-K4GlakiE42J9AWwAu3BWn3Qbf+N8C6vE4eEm5LEx7HqAhHG+FA0U7/3vvXoj31rnS/8sgPQpc2msLWQlmCtZyw== +"@asyncapi/avro-schema-parser@^0.2.0": + version "0.2.0" + resolved "https://registry.npmjs.org/@asyncapi/avro-schema-parser/-/avro-schema-parser-0.2.0.tgz#c9da2bb2858aca5a3b9e9a0e600084afd3765551" + integrity sha512-/oPDPudF82RGFXz5uhF77PSDZkAR+yHpdxUEbJ5jwk/X3RB74VRk8dqSgTqhUO+pLh+/Hut3x3VFacA8C9L2QA== "@asyncapi/openapi-schema-parser@^2.0.0": version "2.0.0" @@ -68,13 +68,13 @@ dependencies: "@openapi-contrib/openapi-schema-to-json-schema" "^3.0.0" -"@asyncapi/parser@^1.0.0-rc.1": - version "1.0.0-rc.2" - resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.0.0-rc.2.tgz#87d2e83c1d390e21d53f868e6209cbc584d35449" - integrity sha512-nZYJLnMiq48q7YHa+AI9ZaDc5UqoqKR7MDhTiIKsIJj6X5fF6hpwBrTP0bQn76Pc1oGBXRB69PcD/Y+Et1qFyw== +"@asyncapi/parser@^1.0.1": + version "1.1.0" + resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.1.0.tgz#b366c85a6322e182e040d60f36400a0b5c26393c" + integrity sha512-0+NeTiW5sPNYaFf4P2VCcy7Z7MLMP7H969cgOp3gvVTKroI5idkYnWo/C16tKAxs+2oOQ9WnPolsNgc+jb/qtg== dependencies: "@apidevtools/json-schema-ref-parser" "^9.0.6" - "@asyncapi/specs" "^2.7.4" + "@asyncapi/specs" "^2.7.5" "@fmvilas/pseudo-yaml-ast" "^0.3.1" ajv "^6.10.1" js-yaml "^3.13.1" @@ -82,18 +82,10 @@ node-fetch "^2.6.0" tiny-merge-patch "^0.1.2" -"@asyncapi/raml-dt-schema-parser@^2.0.0": - version "2.0.0" - resolved "https://registry.npmjs.org/@asyncapi/raml-dt-schema-parser/-/raml-dt-schema-parser-2.0.0.tgz#6e9eff89032aa7b82a963d8e72e320e493fc3835" - integrity sha512-ve41LIvbqDU8s0ZeDrWy+dyQZ/XSb7vKiJUHNgy8xcgxsz4YuttbEJbyGJvarw3qf2y+0y6cA1KUFiXoE+AIUg== - dependencies: - js-yaml "^3.13.1" - ramldt2jsonschema "^1.1.0" - -"@asyncapi/specs@^2.7.4": - version "2.7.4" - resolved "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.7.4.tgz#5db4390b68aeb40d70c1723d8f46b0a091110afc" - integrity sha512-3Np9ip1Qn5AgnxTrbI0CMW2F/WUorpiAKz+GUfEy4a6GPk0eTSI6CIcbx2/jej5P91nhEyny9+D3oIzn2MreTA== +"@asyncapi/specs@^2.7.5": + version "2.7.5" + resolved "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.7.5.tgz#3a516d198fc41a1103695bd889fdd4fbbebe7f5d" + integrity sha512-T1Ham9sqZKCtSowXRPaBCRy2oz3KHglqqrKiaO7lEudpP6lwH5SwXaq4qliyKzWaqd22srJHE4szdsorbFZKlw== "@babel/code-frame@7.0.0": version "7.0.0" @@ -1297,6 +1289,42 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" +"@backstage/core@^0.2.0": + version "0.3.1" + dependencies: + "@backstage/config" "^0.1.1" + "@backstage/core-api" "^0.2.1" + "@backstage/theme" "^0.2.1" + "@material-ui/core" "^4.11.0" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + "@types/dagre" "^0.7.44" + "@types/react" "^16.9" + "@types/react-sparklines" "^1.7.0" + classnames "^2.2.6" + clsx "^1.1.0" + d3-selection "^2.0.0" + d3-shape "^2.0.0" + d3-zoom "^2.0.0" + dagre "^0.8.5" + immer "^7.0.9" + lodash "^4.17.15" + material-table "^1.69.1" + prop-types "^15.7.2" + qs "^6.9.4" + rc-progress "^3.0.0" + react "^16.12.0" + react-dom "^16.12.0" + react-helmet "6.1.0" + react-hook-form "^6.6.0" + react-markdown "^5.0.2" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-sparklines "^1.7.0" + react-syntax-highlighter "^13.5.1" + react-use "^15.3.3" + remark-gfm "^1.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1325,10 +1353,10 @@ resolve-from "^5.0.0" semver "^5.4.1" -"@changesets/assemble-release-plan@^3.0.0", "@changesets/assemble-release-plan@^3.0.1": - version "3.0.1" - resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-3.0.1.tgz#cd501d0c57d435a594fc7bb630fa589d5b75c2a0" - integrity sha512-PChmYuibH8RPiebMIzuYZ/DFS8ehf7yq+X5X0rJklg2njP3zYWJX7nlctpnBhZ0zpgvP2IgrtUigoVGNkv5m/Q== +"@changesets/assemble-release-plan@^4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.0.0.tgz#60c2392c0e2c99f24778ab3a5c8e8c80ddaaaa59" + integrity sha512-3Kv21FNvysTQvZs3fHr6aZeDibhZHtgI1++fMZplzVtwNVmpjow3zv9lcZmJP26LthbpVH3I8+nqlU7M43lfWA== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" @@ -1337,23 +1365,23 @@ "@manypkg/get-packages" "^1.0.1" semver "^5.4.1" -"@changesets/cli@2.10.2": - version "2.10.2" - resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.10.2.tgz#85a7a8d8aca2ef682c671f352645bc75c72f9b85" - integrity sha512-m4YwTmT0ElOuBD3GCbbT75EWmdU4uCFARE4X+Ml1/knc4Z/MEzOjV0bi0/9eHACcQIMNg7rdB6f4vXtHiQU6bA== +"@changesets/cli@^2.11.0": + version "2.11.0" + resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.11.0.tgz#048449289da95f24d96b187f3148c2955065d609" + integrity sha512-imsXWxl+QpLt6PYHSiuZS7xPdKrGs2c8saSG4ENzeZBgnTBMiJdxDloOT6Bmv15ICggHZj9mnaUBNOMPbvnlbA== dependencies: "@babel/runtime" "^7.10.4" "@changesets/apply-release-plan" "^4.0.0" - "@changesets/assemble-release-plan" "^3.0.1" - "@changesets/config" "^1.3.0" + "@changesets/assemble-release-plan" "^4.0.0" + "@changesets/config" "^1.4.0" "@changesets/errors" "^0.1.4" "@changesets/get-dependents-graph" "^1.1.3" - "@changesets/get-release-plan" "^2.0.0" + "@changesets/get-release-plan" "^2.0.1" "@changesets/git" "^1.0.6" "@changesets/logger" "^0.0.5" "@changesets/pre" "^1.0.4" "@changesets/read" "^0.4.6" - "@changesets/types" "^3.1.1" + "@changesets/types" "^3.2.0" "@changesets/write" "^0.1.3" "@manypkg/get-packages" "^1.0.1" "@types/semver" "^6.0.0" @@ -1373,7 +1401,7 @@ term-size "^2.1.0" tty-table "^2.7.0" -"@changesets/config@^1.2.0", "@changesets/config@^1.3.0": +"@changesets/config@^1.2.0": version "1.3.0" resolved "https://registry.npmjs.org/@changesets/config/-/config-1.3.0.tgz#82fcbf572b00ba16636be9ea45167983f1fc203b" integrity sha512-IeAHmN5kI7OywBUNJXsk/v4vcXDDscwgTe/K5D3FSng5QTvzbgiMAe5K1iwBxBvuT4u/33n89kxSJdg4TTTFfA== @@ -1385,6 +1413,19 @@ "@manypkg/get-packages" "^1.0.1" fs-extra "^7.0.1" +"@changesets/config@^1.4.0": + version "1.4.0" + resolved "https://registry.npmjs.org/@changesets/config/-/config-1.4.0.tgz#c157a4121f198b749f2bbc2e9015b6e976ece7d6" + integrity sha512-eoTOcJ6py7jBDY8rUXwEGxR5UtvUX+p//0NhkVpPGcnvIeITHq+DOIsuWyGzWcb+1FaYkof3CCr32/komZTu4Q== + dependencies: + "@changesets/errors" "^0.1.4" + "@changesets/get-dependents-graph" "^1.1.3" + "@changesets/logger" "^0.0.5" + "@changesets/types" "^3.2.0" + "@manypkg/get-packages" "^1.0.1" + fs-extra "^7.0.1" + micromatch "^4.0.2" + "@changesets/errors@^0.1.4": version "0.1.4" resolved "https://registry.npmjs.org/@changesets/errors/-/errors-0.1.4.tgz#f79851746c43679a66b383fdff4c012f480f480d" @@ -1403,13 +1444,13 @@ fs-extra "^7.0.1" semver "^5.4.1" -"@changesets/get-release-plan@^2.0.0": - version "2.0.0" - resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-2.0.0.tgz#570dbd0abcdd4169a73e8332ec139a01130f3b72" - integrity sha512-MHbgXMhkfWhXH1zUefrdtQ8IR+H46lAcKthKjptV28k0qGEcDk7KriYLukJ6BNkWiZkkZ/aycaivbNDclF9zaw== +"@changesets/get-release-plan@^2.0.1": + version "2.0.1" + resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-2.0.1.tgz#b95d8f1a3cc719ff4b42b9b9aae72458d8787c13" + integrity sha512-+x5N9/Iaka+c0Kq7+3JsboMNyffKYlWPmdm+VeafDcMwJFhBDkxm84qaCJ93ydmnzQOTig6gYVqw0k8BbHExyQ== dependencies: - "@babel/runtime" "^7.4.4" - "@changesets/assemble-release-plan" "^3.0.0" + "@babel/runtime" "^7.10.4" + "@changesets/assemble-release-plan" "^4.0.0" "@changesets/config" "^1.2.0" "@changesets/pre" "^1.0.4" "@changesets/read" "^0.4.6" @@ -1478,6 +1519,11 @@ resolved "https://registry.npmjs.org/@changesets/types/-/types-3.1.1.tgz#447481380c42044a8788e46c0dbdf592b338b62f" integrity sha512-XWGEGWXhM92zvBWiQt2sOwhjTt8eCQbrsRbqkv4WYwW3Zsl4qPpvhHsNt845S42dJXrxgjWvId+jxFQocCayNQ== +"@changesets/types@^3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@changesets/types/-/types-3.2.0.tgz#d8306d7219c3b19b6d860ddeb9d7374e2dd6b035" + integrity sha512-rAmPtOyXpisEEE25CchKNUAf2ApyAeuZ/h78YDoqKZaCk5tUD0lgYZGPIRV9WTPoqNjJULIym37ogc6pkax5jg== + "@changesets/write@^0.1.3": version "0.1.3" resolved "https://registry.npmjs.org/@changesets/write/-/write-0.1.3.tgz#00ae575af50274773d7493e77fb96838a08ad8ad" @@ -1655,6 +1701,22 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== +"@eslint/eslintrc@^0.2.1": + version "0.2.1" + resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.2.1.tgz#f72069c330461a06684d119384435e12a5d76e3c" + integrity sha512-XRUeBZ5zBWLYgSANMpThFddrZZkEbGHgUdt5UJjZfnlN9BGCiUBrf+nvbRupSjMvqzwnQN0qwCmOxITt1cfywA== + dependencies: + ajv "^6.12.4" + debug "^4.1.1" + espree "^7.3.0" + globals "^12.1.0" + ignore "^4.0.6" + import-fresh "^3.2.1" + js-yaml "^3.13.1" + lodash "^4.17.19" + minimatch "^3.0.4" + strip-json-comments "^3.1.1" + "@evocateur/libnpmaccess@^3.1.2": version "3.1.2" resolved "https://registry.npmjs.org/@evocateur/libnpmaccess/-/libnpmaccess-3.1.2.tgz#ecf7f6ce6b004e9f942b098d92200be4a4b1c845" @@ -1736,32 +1798,33 @@ dependencies: yaml-ast-parser "0.0.43" -"@gitbeaker/core@^23.5.0": - version "23.5.0" - resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-23.5.0.tgz#e0be44eecd0d7bf5418c161997c36d45b0c1ba81" - integrity sha512-5geLk7SxAttABBJZxfuSp72lSYWRyRId583vGCpKB6ISkoDhP+vJxdE6ypmtOJPV4CaSRZhluAGAZd968pi9ng== +"@gitbeaker/core@^25.2.0": + version "25.2.0" + resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-25.2.0.tgz#c2f46b65ed88aebfa69afd2e58dbf4ebe2efb2c7" + integrity sha512-dhCvZItI8FIzHtJ9EySQ43GmNg3j39MxDGMCpDHn+Qb1o54QS+J6XPVQrMRmJioZIyj+WZQPX/IP+/mRkx8vhg== dependencies: - "@gitbeaker/requester-utils" "^23.5.0" + "@gitbeaker/requester-utils" "^25.2.0" form-data "^3.0.0" li "^1.3.0" xcase "^2.0.1" -"@gitbeaker/node@^23.5.0": - version "23.5.0" - resolved "https://registry.npmjs.org/@gitbeaker/node/-/node-23.5.0.tgz#0243be78aad148e7d6b5d79b7acc340748b5fa94" - integrity sha512-GEyhcrF1Lm8YmsmwntfzuhXnq00TrG14wNZP2Hg+DGgexG25eBbbcyFXuFZlBFSaGMQlsc8aPeuEyfyGmgpwnw== +"@gitbeaker/node@^25.2.0": + version "25.2.0" + resolved "https://registry.npmjs.org/@gitbeaker/node/-/node-25.2.0.tgz#cc91e83328ec32de0b1a0dac23accd2385734a66" + integrity sha512-FWchXYJ5agn0ptAQxtkkSKSg1ObbP2xfMzHLECxINFRBHYhg0ms8Fp8Qb+71pxJz7IMlvajyEtZaPfHBmyuh9Q== dependencies: - "@gitbeaker/core" "^23.5.0" - "@gitbeaker/requester-utils" "^23.5.0" - got "^11.1.4" + "@gitbeaker/core" "^25.2.0" + "@gitbeaker/requester-utils" "^25.2.0" + got "^11.7.0" xcase "^2.0.1" -"@gitbeaker/requester-utils@^23.5.0": - version "23.5.0" - resolved "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-23.5.0.tgz#e6f5d0216cb978be95e6bf40adf606f23d426f14" - integrity sha512-MdmInOO4unkApvtbv6PnIpDYXosgZgClSOqbxF5S4aJRCZVTJ6oPjMoNP8luhyT9xQeknpKxn9Iv8psEh7IC1Q== +"@gitbeaker/requester-utils@^25.2.0": + version "25.2.0" + resolved "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-25.2.0.tgz#f71c44e0073617877f9875dd00c4ae0d74897b09" + integrity sha512-pjuFIVlbxSTPdN+zFT/LBP4ym8k0OBYwUpc5WkzoOtvdTGuDX05r8ufnV07kibLDJkwDmjwnH4Hsc66yevSQTw== dependencies: - query-string "^6.12.1" + form-data "^3.0.0" + query-string "^6.13.3" xcase "^2.0.1" "@graphql-codegen/cli@^1.17.7": @@ -2235,93 +2298,98 @@ resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== -"@jest/console@^26.3.0": - version "26.3.0" - resolved "https://registry.npmjs.org/@jest/console/-/console-26.3.0.tgz#ed04063efb280c88ba87388b6f16427c0a85c856" - integrity sha512-/5Pn6sJev0nPUcAdpJHMVIsA8sKizL2ZkcKPE5+dJrCccks7tcM7c9wbgHudBJbxXLoTbqsHkG1Dofoem4F09w== +"@jest/console@^26.5.2": + version "26.5.2" + resolved "https://registry.npmjs.org/@jest/console/-/console-26.5.2.tgz#94fc4865b1abed7c352b5e21e6c57be4b95604a6" + integrity sha512-lJELzKINpF1v74DXHbCRIkQ/+nUV1M+ntj+X1J8LxCgpmJZjfLmhFejiMSbjjD66fayxl5Z06tbs3HMyuik6rw== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" "@types/node" "*" chalk "^4.0.0" - jest-message-util "^26.3.0" - jest-util "^26.3.0" + jest-message-util "^26.5.2" + jest-util "^26.5.2" slash "^3.0.0" -"@jest/core@^26.4.2": - version "26.4.2" - resolved "https://registry.npmjs.org/@jest/core/-/core-26.4.2.tgz#85d0894f31ac29b5bab07aa86806d03dd3d33edc" - integrity sha512-sDva7YkeNprxJfepOctzS8cAk9TOekldh+5FhVuXS40+94SHbiicRO1VV2tSoRtgIo+POs/Cdyf8p76vPTd6dg== +"@jest/core@^26.5.3": + version "26.5.3" + resolved "https://registry.npmjs.org/@jest/core/-/core-26.5.3.tgz#712ed4adb64c3bda256a3f400ff1d3eb2a031f13" + integrity sha512-CiU0UKFF1V7KzYTVEtFbFmGLdb2g4aTtY0WlyUfLgj/RtoTnJFhh50xKKr7OYkdmBUlGFSa2mD1TU3UZ6OLd4g== dependencies: - "@jest/console" "^26.3.0" - "@jest/reporters" "^26.4.1" - "@jest/test-result" "^26.3.0" - "@jest/transform" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/console" "^26.5.2" + "@jest/reporters" "^26.5.3" + "@jest/test-result" "^26.5.2" + "@jest/transform" "^26.5.2" + "@jest/types" "^26.5.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" - jest-changed-files "^26.3.0" - jest-config "^26.4.2" - jest-haste-map "^26.3.0" - jest-message-util "^26.3.0" + jest-changed-files "^26.5.2" + jest-config "^26.5.3" + jest-haste-map "^26.5.2" + jest-message-util "^26.5.2" jest-regex-util "^26.0.0" - jest-resolve "^26.4.0" - jest-resolve-dependencies "^26.4.2" - jest-runner "^26.4.2" - jest-runtime "^26.4.2" - jest-snapshot "^26.4.2" - jest-util "^26.3.0" - jest-validate "^26.4.2" - jest-watcher "^26.3.0" + jest-resolve "^26.5.2" + jest-resolve-dependencies "^26.5.3" + jest-runner "^26.5.3" + jest-runtime "^26.5.3" + jest-snapshot "^26.5.3" + jest-util "^26.5.2" + jest-validate "^26.5.3" + jest-watcher "^26.5.2" micromatch "^4.0.2" p-each-series "^2.1.0" rimraf "^3.0.0" slash "^3.0.0" strip-ansi "^6.0.0" -"@jest/environment@^26.3.0": - version "26.3.0" - resolved "https://registry.npmjs.org/@jest/environment/-/environment-26.3.0.tgz#e6953ab711ae3e44754a025f838bde1a7fd236a0" - integrity sha512-EW+MFEo0DGHahf83RAaiqQx688qpXgl99wdb8Fy67ybyzHwR1a58LHcO376xQJHfmoXTu89M09dH3J509cx2AA== - dependencies: - "@jest/fake-timers" "^26.3.0" - "@jest/types" "^26.3.0" - "@types/node" "*" - jest-mock "^26.3.0" +"@jest/create-cache-key-function@^26.5.0": + version "26.5.0" + resolved "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-26.5.0.tgz#1d07947adc51ea17766d9f0ccf5a8d6ea94c47dc" + integrity sha512-DJ+pEBUIqarrbv1W/C39f9YH0rJ4wsXZ/VC6JafJPlHW2HOucKceeaqTOQj9MEDQZjySxMLkOq5mfXZXNZcmWw== -"@jest/fake-timers@^26.3.0": - version "26.3.0" - resolved "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.3.0.tgz#f515d4667a6770f60ae06ae050f4e001126c666a" - integrity sha512-ZL9ytUiRwVP8ujfRepffokBvD2KbxbqMhrXSBhSdAhISCw3gOkuntisiSFv+A6HN0n0fF4cxzICEKZENLmW+1A== +"@jest/environment@^26.5.2": + version "26.5.2" + resolved "https://registry.npmjs.org/@jest/environment/-/environment-26.5.2.tgz#eba3cfc698f6e03739628f699c28e8a07f5e65fe" + integrity sha512-YjhCD/Zhkz0/1vdlS/QN6QmuUdDkpgBdK4SdiVg4Y19e29g4VQYN5Xg8+YuHjdoWGY7wJHMxc79uDTeTOy9Ngw== dependencies: - "@jest/types" "^26.3.0" + "@jest/fake-timers" "^26.5.2" + "@jest/types" "^26.5.2" + "@types/node" "*" + jest-mock "^26.5.2" + +"@jest/fake-timers@^26.5.2": + version "26.5.2" + resolved "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.5.2.tgz#1291ac81680ceb0dc7daa1f92c059307eea6400a" + integrity sha512-09Hn5Oraqt36V1akxQeWMVL0fR9c6PnEhpgLaYvREXZJAh2H2Y+QLCsl0g7uMoJeoWJAuz4tozk1prbR1Fc1sw== + dependencies: + "@jest/types" "^26.5.2" "@sinonjs/fake-timers" "^6.0.1" "@types/node" "*" - jest-message-util "^26.3.0" - jest-mock "^26.3.0" - jest-util "^26.3.0" + jest-message-util "^26.5.2" + jest-mock "^26.5.2" + jest-util "^26.5.2" -"@jest/globals@^26.4.2": - version "26.4.2" - resolved "https://registry.npmjs.org/@jest/globals/-/globals-26.4.2.tgz#73c2a862ac691d998889a241beb3dc9cada40d4a" - integrity sha512-Ot5ouAlehhHLRhc+sDz2/9bmNv9p5ZWZ9LE1pXGGTCXBasmi5jnYjlgYcYt03FBwLmZXCZ7GrL29c33/XRQiow== +"@jest/globals@^26.5.3": + version "26.5.3" + resolved "https://registry.npmjs.org/@jest/globals/-/globals-26.5.3.tgz#90769b40e0af3fa0b28f6d8c5bbe3712467243fd" + integrity sha512-7QztI0JC2CuB+Wx1VdnOUNeIGm8+PIaqngYsZXQCkH2QV0GFqzAYc9BZfU0nuqA6cbYrWh5wkuMzyii3P7deug== dependencies: - "@jest/environment" "^26.3.0" - "@jest/types" "^26.3.0" - expect "^26.4.2" + "@jest/environment" "^26.5.2" + "@jest/types" "^26.5.2" + expect "^26.5.3" -"@jest/reporters@^26.4.1": - version "26.4.1" - resolved "https://registry.npmjs.org/@jest/reporters/-/reporters-26.4.1.tgz#3b4d6faf28650f3965f8b97bc3d114077fb71795" - integrity sha512-aROTkCLU8++yiRGVxLsuDmZsQEKO6LprlrxtAuzvtpbIFl3eIjgIf3EUxDKgomkS25R9ZzwGEdB5weCcBZlrpQ== +"@jest/reporters@^26.5.3": + version "26.5.3" + resolved "https://registry.npmjs.org/@jest/reporters/-/reporters-26.5.3.tgz#e810e9c2b670f33f1c09e9975749260ca12f1c17" + integrity sha512-X+vR0CpfMQzYcYmMFKNY9n4jklcb14Kffffp7+H/MqitWnb0440bW2L76NGWKAa+bnXhNoZr+lCVtdtPmfJVOQ== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^26.3.0" - "@jest/test-result" "^26.3.0" - "@jest/transform" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/console" "^26.5.2" + "@jest/test-result" "^26.5.2" + "@jest/transform" "^26.5.2" + "@jest/types" "^26.5.2" chalk "^4.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" @@ -2332,63 +2400,63 @@ istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.0.2" - jest-haste-map "^26.3.0" - jest-resolve "^26.4.0" - jest-util "^26.3.0" - jest-worker "^26.3.0" + jest-haste-map "^26.5.2" + jest-resolve "^26.5.2" + jest-util "^26.5.2" + jest-worker "^26.5.0" slash "^3.0.0" source-map "^0.6.0" string-length "^4.0.1" terminal-link "^2.0.0" - v8-to-istanbul "^5.0.1" + v8-to-istanbul "^6.0.1" optionalDependencies: node-notifier "^8.0.0" -"@jest/source-map@^26.3.0": - version "26.3.0" - resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-26.3.0.tgz#0e646e519883c14c551f7b5ae4ff5f1bfe4fc3d9" - integrity sha512-hWX5IHmMDWe1kyrKl7IhFwqOuAreIwHhbe44+XH2ZRHjrKIh0LO5eLQ/vxHFeAfRwJapmxuqlGAEYLadDq6ZGQ== +"@jest/source-map@^26.5.0": + version "26.5.0" + resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-26.5.0.tgz#98792457c85bdd902365cd2847b58fff05d96367" + integrity sha512-jWAw9ZwYHJMe9eZq/WrsHlwF8E3hM9gynlcDpOyCb9bR8wEd9ZNBZCi7/jZyzHxC7t3thZ10gO2IDhu0bPKS5g== dependencies: callsites "^3.0.0" graceful-fs "^4.2.4" source-map "^0.6.0" -"@jest/test-result@^26.3.0": - version "26.3.0" - resolved "https://registry.npmjs.org/@jest/test-result/-/test-result-26.3.0.tgz#46cde01fa10c0aaeb7431bf71e4a20d885bc7fdb" - integrity sha512-a8rbLqzW/q7HWheFVMtghXV79Xk+GWwOK1FrtimpI5n1la2SY0qHri3/b0/1F0Ve0/yJmV8pEhxDfVwiUBGtgg== +"@jest/test-result@^26.5.2": + version "26.5.2" + resolved "https://registry.npmjs.org/@jest/test-result/-/test-result-26.5.2.tgz#cc1a44cfd4db2ecee3fb0bc4e9fe087aa54b5230" + integrity sha512-E/Zp6LURJEGSCWpoMGmCFuuEI1OWuI3hmZwmULV0GsgJBh7u0rwqioxhRU95euUuviqBDN8ruX/vP/4bwYolXw== dependencies: - "@jest/console" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/console" "^26.5.2" + "@jest/types" "^26.5.2" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^26.4.2": - version "26.4.2" - resolved "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.4.2.tgz#58a3760a61eec758a2ce6080201424580d97cbba" - integrity sha512-83DRD8N3M0tOhz9h0bn6Kl6dSp+US6DazuVF8J9m21WAp5x7CqSMaNycMP0aemC/SH/pDQQddbsfHRTBXVUgog== +"@jest/test-sequencer@^26.5.3": + version "26.5.3" + resolved "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.5.3.tgz#9ae0ab9bc37d5171b28424029192e50229814f8d" + integrity sha512-Wqzb7aQ13L3T47xHdpUqYMOpiqz6Dx2QDDghp5AV/eUDXR7JieY+E1s233TQlNyl+PqtqgjVokmyjzX/HA51BA== dependencies: - "@jest/test-result" "^26.3.0" + "@jest/test-result" "^26.5.2" graceful-fs "^4.2.4" - jest-haste-map "^26.3.0" - jest-runner "^26.4.2" - jest-runtime "^26.4.2" + jest-haste-map "^26.5.2" + jest-runner "^26.5.3" + jest-runtime "^26.5.3" -"@jest/transform@^26.3.0": - version "26.3.0" - resolved "https://registry.npmjs.org/@jest/transform/-/transform-26.3.0.tgz#c393e0e01459da8a8bfc6d2a7c2ece1a13e8ba55" - integrity sha512-Isj6NB68QorGoFWvcOjlUhpkT56PqNIsXKR7XfvoDlCANn/IANlh8DrKAA2l2JKC3yWSMH5wS0GwuQM20w3b2A== +"@jest/transform@^26.5.2": + version "26.5.2" + resolved "https://registry.npmjs.org/@jest/transform/-/transform-26.5.2.tgz#6a0033a1d24316a1c75184d010d864f2c681bef5" + integrity sha512-AUNjvexh+APhhmS8S+KboPz+D3pCxPvEAGduffaAJYxIFxGi/ytZQkrqcKDUU0ERBAo5R7087fyOYr2oms1seg== dependencies: "@babel/core" "^7.1.0" - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" babel-plugin-istanbul "^6.0.0" chalk "^4.0.0" convert-source-map "^1.4.0" fast-json-stable-stringify "^2.0.0" graceful-fs "^4.2.4" - jest-haste-map "^26.3.0" + jest-haste-map "^26.5.2" jest-regex-util "^26.0.0" - jest-util "^26.3.0" + jest-util "^26.5.2" micromatch "^4.0.2" pirates "^4.0.1" slash "^3.0.0" @@ -2405,10 +2473,10 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" -"@jest/types@^26.3.0": - version "26.3.0" - resolved "https://registry.npmjs.org/@jest/types/-/types-26.3.0.tgz#97627bf4bdb72c55346eef98e3b3f7ddc4941f71" - integrity sha512-BDPG23U0qDeAvU4f99haztXwdAg3hz4El95LkAM+tHAqqhiVzRpEGHHU8EDxT/AnxOrA65YjLBwDahdJ9pTLJQ== +"@jest/types@^26.5.2", "@jest/types@^26.6.1": + version "26.6.1" + resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5" + integrity sha512-ywHavIKNpAVrStiRY5wiyehvcktpijpItvGiK72RAn5ctqmzvPk8OvKnvHeBqa1XdQr959CTWAJMqxI8BTibyg== dependencies: "@types/istanbul-lib-coverage" "^2.0.0" "@types/istanbul-reports" "^3.0.0" @@ -2461,19 +2529,17 @@ dependencies: stream "^0.0.2" -"@kyma-project/asyncapi-react@^0.13.1": - version "0.13.1" - resolved "https://registry.npmjs.org/@kyma-project/asyncapi-react/-/asyncapi-react-0.13.1.tgz#8a92ee2df996540240c0afa17adfa566e4cee18d" - integrity sha512-d+52fzfBHpcC32WvrfB6sSyQFgzda/lHrKSn5klXP6tEiOU5uaLEIQzvZLCL8kXtuHiknozHO3h/BfviqEkwww== +"@kyma-project/asyncapi-react@^0.14.2": + version "0.14.2" + resolved "https://registry.npmjs.org/@kyma-project/asyncapi-react/-/asyncapi-react-0.14.2.tgz#6d99ef878f0481b05db0f3be7c94f4534176675f" + integrity sha512-lf3zcIuCTaVEIkCpF7Vwulrucgdfp1zXUIvkqjYPteOJjOuT6BAvnYwgvFUYibitmD/ADkvK8ybIeKdNJcyaPw== dependencies: - "@asyncapi/avro-schema-parser" "^0.1.2" + "@asyncapi/avro-schema-parser" "^0.2.0" "@asyncapi/openapi-schema-parser" "^2.0.0" - "@asyncapi/parser" "^1.0.0-rc.1" - "@asyncapi/raml-dt-schema-parser" "^2.0.0" + "@asyncapi/parser" "^1.0.1" constate "^1.2.0" - dompurify "^1.0.11" - json-schema-ref-parser "^9.0.6" - markdown-it "^9.1.0" + dompurify "^2.1.1" + markdown-it "^11.0.1" merge "^1.2.1" openapi-sampler "^1.0.0-beta.15" react-use "^12.2.0" @@ -3599,10 +3665,10 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" -"@rjsf/core@^2.1.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.3.0.tgz#334c73d2262ef1a8cda477e238067af7336c5599" - integrity sha512-OZKYHt9tjKhzOH4CvsPiCwepuIacqI++cNmnL2fsxh1IF+uEWGlo3NLDWhhSaBbOv9jps6a5YQcLbLtjNuSwug== +"@rjsf/core@^2.4.0": + version "2.4.0" + resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.4.0.tgz#c50bcff0d8178948ce08123177399d8816d51929" + integrity sha512-8zlydBkGldOxGXFEwNGFa1gzTxpcxaYn7ofegcu8XHJ7IKMCfpnU3ABg+H3eml1KZCX3FODmj1tHFJKuTmfynw== dependencies: "@babel/runtime-corejs2" "^7.8.7" "@types/json-schema" "^7.0.4" @@ -3616,20 +3682,58 @@ react-is "^16.9.0" shortid "^2.2.14" -"@rjsf/material-ui@^2.1.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.3.0.tgz#a051eb4db2ad778e39933a31ba806f415dd3ba90" - integrity sha512-v/xZ4Xk18ZgBARcCe99IDqdO97GU0UC784gG8PhGGFDdy5Rbdy7ixTjHkGYttkr43PB7SX6ttohNhkKSW4nATQ== +"@rjsf/material-ui@^2.4.0": + version "2.4.0" + resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.4.0.tgz#1b5859298bf3f61137d7b05084f058a775d6fd73" + integrity sha512-U8F/suzg4MuV+8mK1/ufs0Y6c3O8hc1wnuD2IKoOVJvegGfz5JCafyoyGAW6iyuT1DZBMPzVWEqfiuYPmoE7pw== -"@roadiehq/backstage-plugin-github-pull-requests@^0.4.3": - version "0.4.3" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.4.3.tgz#720fd3c53ae8e61d8cfc727cda47e236af442b02" - integrity sha512-IJGMys5FJ512sitaMwWgACll3ZTuE3nZUX05MNXly/WXty6VBcfboiPbSB6IzLAEWMmvYlV1YUwwL7hAl/62GQ== +"@roadiehq/backstage-plugin-buildkite@^0.1.2": + version "0.1.2" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-buildkite/-/backstage-plugin-buildkite-0.1.2.tgz#2f2b414acc18ed7820abc6fb0c042f00c31d7c9a" + integrity sha512-NO1ogAK6lfh/YUmftqbYn6pkUmMJXk9cfda0YDnG23XlcKIst1d3xO5FggozVtU5IZiJDxxwxOOo4ylhBWrGeQ== dependencies: - "@backstage/catalog-model" "^0.1.1-alpha.23" - "@backstage/core" "^0.1.1-alpha.23" - "@backstage/plugin-catalog" "^0.1.1-alpha.23" - "@backstage/theme" "^0.1.1-alpha.23" + "@backstage/catalog-model" "^0.2.0" + "@backstage/core" "^0.2.0" + "@backstage/plugin-catalog" "^0.2.0" + "@material-ui/core" "^4.11.0" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + moment "^2.27.0" + react "^16.13.1" + react-dom "^16.13.1" + react-lazylog "^4.5.2" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-use "^15.3.3" + +"@roadiehq/backstage-plugin-github-insights@^0.2.14": + version "0.2.14" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-0.2.14.tgz#2e4bf61495e650bb2b7a1711f97c9c4995215588" + integrity sha512-xhzHAmmTu7op1V7K3ytomGlmzHMs9jnb2Lc2YTrU3ame4WZcrrMCrqi8X9v8B8VHfwMBrwa55tyxRYObXrPr7A== + dependencies: + "@backstage/catalog-model" "^0.2.0" + "@backstage/core" "^0.2.0" + "@backstage/theme" "^0.2.0" + "@material-ui/core" "^4.11.0" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + "@octokit/rest" "^18.0.0" + "@octokit/types" "^5.0.1" + history "^5.0.0" + react "^16.13.1" + react-dom "^16.13.1" + react-router "^6.0.0-beta.0" + react-use "^15.3.3" + +"@roadiehq/backstage-plugin-github-pull-requests@^0.6.2": + version "0.6.2" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.6.2.tgz#02f4a7a03e1a7dc24342ec695130017ce7d3ad8d" + integrity sha512-OdBWO6NdiBKlol1WlbFQGeHxV2C8wl8EiI2NgA15lozsqFA22kWis6Z16cAHe4ZdRCjihDmqINtAIJzeZZ1gOg== + dependencies: + "@backstage/catalog-model" "^0.2.0" + "@backstage/core" "^0.2.0" + "@backstage/plugin-catalog" "^0.2.0" + "@backstage/theme" "^0.2.0" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "^4.0.0-alpha.56" @@ -3643,16 +3747,16 @@ react-router "6.0.0-beta.0" react-use "^15.3.3" -"@roadiehq/backstage-plugin-travis-ci@^0.2.3": - version "0.2.3" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.2.3.tgz#2d1c5a7ed3eab4fdcf95243b73dee44c0cff5a57" - integrity sha512-Xi8vZFbcm+D4ykyU3I9hImAd22F1v6M1F/H9CJanRHqj5esJxnuCS2+Kf+w09bwb9DuM8/c68PuMlf3PMIsOwg== +"@roadiehq/backstage-plugin-travis-ci@^0.2.7": + version "0.2.7" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.2.7.tgz#bc7968b461016b2710794d10766266de9ab4f759" + integrity sha512-uXF5t2uZqd9TNGFSMYlyk6NAweVb5KLlM4GMiltattzxRWiqbCp5VumubrLTTsA8dvsWCrXWfhe95MStcUnl+A== dependencies: - "@backstage/catalog-model" "^0.1.1-alpha.22" - "@backstage/core" "^0.1.1-alpha.22" - "@backstage/core-api" "^0.1.1-alpha.22" - "@backstage/plugin-catalog" "^0.1.1-alpha.22" - "@backstage/theme" "^0.1.1-alpha.22" + "@backstage/catalog-model" "^0.2.0" + "@backstage/core" "^0.2.0" + "@backstage/core-api" "^0.2.0" + "@backstage/plugin-catalog" "^0.2.0" + "@backstage/theme" "^0.2.0" "@material-ui/core" "^4.9.1" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.45" @@ -3666,18 +3770,18 @@ react-router-dom "6.0.0-beta.0" react-use "^15.3.3" -"@rollup/plugin-commonjs@^13.0.0": - version "13.0.0" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-13.0.0.tgz#8a1d684ba6848afe8b9e3d85649d4b2f6f7217ec" - integrity sha512-Anxc3qgkAi7peAyesTqGYidG5GRim9jtg8xhmykNaZkImtvjA7Wsqep08D2mYsqw1IF7rA3lYfciLgzUSgRoqw== +"@rollup/plugin-commonjs@^16.0.0": + version "16.0.0" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-16.0.0.tgz#169004d56cd0f0a1d0f35915d31a036b0efe281f" + integrity sha512-LuNyypCP3msCGVQJ7ki8PqYdpjfEkE/xtFa5DqlF+7IBD0JsfMZ87C58heSwIMint58sAUZbt3ITqOmdQv/dXw== dependencies: - "@rollup/pluginutils" "^3.0.8" + "@rollup/pluginutils" "^3.1.0" commondir "^1.0.1" - estree-walker "^1.0.1" - glob "^7.1.2" - is-reference "^1.1.2" - magic-string "^0.25.2" - resolve "^1.11.0" + estree-walker "^2.0.1" + glob "^7.1.6" + is-reference "^1.2.1" + magic-string "^0.25.7" + resolve "^1.17.0" "@rollup/plugin-json@^4.0.2": version "4.1.0" @@ -3686,15 +3790,14 @@ dependencies: "@rollup/pluginutils" "^3.0.8" -"@rollup/plugin-node-resolve@^8.1.0": - version "8.4.0" - resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-8.4.0.tgz#261d79a680e9dc3d86761c14462f24126ba83575" - integrity sha512-LFqKdRLn0ShtQyf6SBYO69bGE1upV6wUhBX0vFOUnLAyzx5cwp8svA0eHUnu8+YU57XOkrMtfG63QOpQx25pHQ== +"@rollup/plugin-node-resolve@^9.0.0": + version "9.0.0" + resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-9.0.0.tgz#39bd0034ce9126b39c1699695f440b4b7d2b62e6" + integrity sha512-gPz+utFHLRrd41WMP13Jq5mqqzHL3OXrfj3/MkSyB6UBIcuNt9j60GCbarzMzdf1VHFpOxfQh/ez7wyadLMqkg== dependencies: "@rollup/pluginutils" "^3.1.0" "@types/resolve" "1.17.1" builtin-modules "^3.1.0" - deep-freeze "^0.0.1" deepmerge "^4.2.2" is-module "^1.0.0" resolve "^1.17.0" @@ -3739,6 +3842,11 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-3.1.2.tgz#548650de521b344e3781fbdb0ece4aa6f729afb8" integrity sha512-JiX9vxoKMmu8Y3Zr2RVathBL1Cdu4Nt4MuNWemt1Nc06A0RAin9c5FArkhGsyMBWfCu4zj+9b+GxtjAnE4qqLQ== +"@sindresorhus/is@^4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.0.tgz#2ff674e9611b45b528896d820d3d7a812de2f0e4" + integrity sha512-FyD2meJpDPjyNQejSjvnhpgI/azsQkA4lGbuu5BQZfjvJ9cbRZXzeWL2HceCekW4lixO9JPesIIQkSoLjeJHNQ== + "@sinonjs/commons@^1.7.0": version "1.7.1" resolved "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.7.1.tgz#da5fd19a5f71177a53778073978873964f49acf1" @@ -3753,10 +3861,10 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@spotify/eslint-config-base@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/@spotify/eslint-config-base/-/eslint-config-base-7.0.0.tgz#36804ae09ec938f1aa5f9464ea993f3f151cfaa8" - integrity sha512-XRTrTRyRRYBxPYINKNHw4B8QWlA3p4I+id/Po2sMdejtXH3LZgDgIjdschUZSdQ6J6dVYDdaVykdizRM9I+G6Q== +"@spotify/eslint-config-base@^9.0.0": + version "9.0.0" + resolved "https://registry.npmjs.org/@spotify/eslint-config-base/-/eslint-config-base-9.0.0.tgz#e122b2e6d1a11750170badc5be6774ded95953d2" + integrity sha512-LNGY6bBM0IlosvMIx9Ic5gqatpa5LPvrJhN4RnrUnP9oaXNLgPTKDE89p3eC5Tnh78ADdX4HD+rbHDSyP2tBgg== "@spotify/eslint-config-oss@^1.0.1": version "1.0.2" @@ -3765,62 +3873,32 @@ dependencies: eslint-plugin-notice "^0.9.10" -"@spotify/eslint-config-react@^7.0.1": - version "7.0.1" - resolved "https://registry.npmjs.org/@spotify/eslint-config-react/-/eslint-config-react-7.0.1.tgz#2e70de9d7911ea9aeaa55a83a332220a28f6c431" - integrity sha512-HNDHvm19EaBXiDgsg52mjMgKWZTeA0hO2Q75ACNwb8UtjIKkANqyyuzyDGo8jiGMbWIm6wJjShlRtT95emNlqQ== +"@spotify/eslint-config-react@^9.0.0": + version "9.0.0" + resolved "https://registry.npmjs.org/@spotify/eslint-config-react/-/eslint-config-react-9.0.0.tgz#9c032e257090a5b3f5b08b28c1311485bf0fbae3" + integrity sha512-3w+cmbUljBLxZGLk4dn1oJD8v4uxW7bKackkcpZhac6VkQmg01EDN04Bx9qmbNe5S/2uHwrOJGcWlge5R8Ml1Q== -"@spotify/eslint-config-typescript@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/@spotify/eslint-config-typescript/-/eslint-config-typescript-7.0.0.tgz#fc5227e3344f74b41ac3a530df24a95ac13254e4" - integrity sha512-28I/SAf68NKbWZ5IY0WYMa0D18PxWdC9DP9gRbOTlZufmsS8jEgqf3zBUWmP6XOf1nihpKWcqvbFUG5H7/JYXA== - -"@spotify/eslint-config@^7.0.0", "@spotify/eslint-config@^7.0.1": - version "7.0.1" - resolved "https://registry.npmjs.org/@spotify/eslint-config/-/eslint-config-7.0.1.tgz#07a21cfd7fce89cfc2c6dd5ea5d747e741201b66" - integrity sha512-8GI/TZGUhS4pr7oipT2MjrZFRgXcKzk9YImEusUdD2f5vlCniRFIBQNrvTMkyjfdQqvIVqJPLcdVPXeAgprsMw== - dependencies: - "@spotify/eslint-config-base" "^7.0.0" - "@spotify/eslint-config-react" "^7.0.1" - "@spotify/eslint-config-typescript" "^7.0.0" - "@spotify/web-scripts-utils" "^7.0.0" - "@typescript-eslint/eslint-plugin" "^2.14.0" - "@typescript-eslint/parser" "^2.14.0" - eslint-config-prettier "^6.0.0" - eslint-plugin-jest "^23.6.0" - eslint-plugin-jsx-a11y "^6.2.1" - eslint-plugin-react "^7.12.4" - eslint-plugin-react-hooks "^4.0.0" - -"@spotify/prettier-config@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-7.0.0.tgz#47750979d1282197295108b6958360660a955c16" - integrity sha512-lIMcx/2oDqTtW84iHKkRJe+8U6HK6GPwWH5sJp9UEHcDpdXomOQYvwcGXy2I2zwPQQ14gYYE6nEJuSnnYqsYRw== +"@spotify/eslint-config-typescript@^9.0.0": + version "9.0.0" + resolved "https://registry.npmjs.org/@spotify/eslint-config-typescript/-/eslint-config-typescript-9.0.0.tgz#be68cfaf212599f0bfeb6536c7c58ec05d2b6fba" + integrity sha512-ZsXTwMA68ZCz943U4N8XwprdWcc7ErOO/IW8PewLK5lycCZtLnmRkOvAbae7O5qNJPD8b/l0iUMTLaZuwjXWwg== "@spotify/prettier-config@^8.0.0": version "8.0.0" resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-8.0.0.tgz#8b6c2bd579ddc54887155a0721fe04e96c89f7f2" integrity sha512-so8w32ZV42CHWxOEXcBtbNO/hLXFrQNXVmhfzhUI6dVB9cq2xjRaiqu8GjFj8LvKbWpPj+S+KwTIS4aDVWqrFQ== -"@spotify/web-scripts-utils@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/@spotify/web-scripts-utils/-/web-scripts-utils-7.0.0.tgz#8c6b8039fc645a36ac48629eb9ba06600f4d828a" - integrity sha512-McMy0j60lxOHjgDjegthZqEWN/PabphiM30A/mI/Y7xh9+JFnYWBTSm/wgn6EW2BsPpV631xSYYkk6B1Ph14Fw== - dependencies: - glob "^7.1.4" - read-pkg-up "^7.0.1" - "@storybook/addon-actions@^6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.0.21.tgz#0de1d109d4b1eb99f644bbe84e74c25cfd2b1b6b" - integrity sha512-9y3ve+3GK1TsxQ5pxDjhB7E/XJXY+WqcSNlOX8Mb+XbS6AAgpFbkZCw1q8CGzyEUclHsQ6UK2+lo+IRGs4TLpA== + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.0.26.tgz#d0de9e4d78a8f8f5bf8730c04d0b6d1741c29273" + integrity sha512-9tWbAqSwzWWVz8zwAndZFusZYjIcRYgZUC0LqC8QlH79DgF3ASjw9y97+w1VTTAzdb6LYnsMuSpX6+8m5hrR4g== dependencies: - "@storybook/addons" "6.0.21" - "@storybook/api" "6.0.21" - "@storybook/client-api" "6.0.21" - "@storybook/components" "6.0.21" - "@storybook/core-events" "6.0.21" - "@storybook/theming" "6.0.21" + "@storybook/addons" "6.0.26" + "@storybook/api" "6.0.26" + "@storybook/client-api" "6.0.26" + "@storybook/components" "6.0.26" + "@storybook/core-events" "6.0.26" + "@storybook/theming" "6.0.26" core-js "^3.0.1" fast-deep-equal "^3.1.1" global "^4.3.2" @@ -3873,7 +3951,7 @@ react-syntax-highlighter "^12.2.1" regenerator-runtime "^0.13.3" -"@storybook/addons@6.0.21", "@storybook/addons@^6.0.21": +"@storybook/addons@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.0.21.tgz#bd5229652102c3aed59b78ef6920ff6b482b4d78" integrity sha512-yDttNLc3vXqBxwK795ykgzTC6MpvuXDQuF4LHSlHZQe6wsMu1m3fljnbYdafJWdx6cNZwUblU3KYcR11PqhkPg== @@ -3888,6 +3966,21 @@ global "^4.3.2" regenerator-runtime "^0.13.3" +"@storybook/addons@6.0.26", "@storybook/addons@^6.0.21": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.0.26.tgz#343cbea3eee2d39413b80bc2d66535a7f61488fc" + integrity sha512-OhAApFKgsj9an7FLYfHI4cJQuZ4Zm6yoGOpaxhOvKQMw7dXUPsLvbCyw/6dZOLvaFhjJjQiXtbxtZG+UjR8nvA== + dependencies: + "@storybook/api" "6.0.26" + "@storybook/channels" "6.0.26" + "@storybook/client-logger" "6.0.26" + "@storybook/core-events" "6.0.26" + "@storybook/router" "6.0.26" + "@storybook/theming" "6.0.26" + core-js "^3.0.1" + global "^4.3.2" + regenerator-runtime "^0.13.3" + "@storybook/api@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/api/-/api-6.0.21.tgz#a25a1eb4d07dc43500e03c856db43baba46726f1" @@ -3914,6 +4007,32 @@ ts-dedent "^1.1.1" util-deprecate "^1.0.2" +"@storybook/api@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.0.26.tgz#c45222c132eb8bc2e383536adfebbeb7a89867d0" + integrity sha512-aszDoz1c6T+eRtTUwWvySoyd3gRXmQxsingD084NnEp4VfFLA5H7VS/0sre0ZvU5GWh8d9COxY0DS2Ry/QSKvw== + dependencies: + "@reach/router" "^1.3.3" + "@storybook/channels" "6.0.26" + "@storybook/client-logger" "6.0.26" + "@storybook/core-events" "6.0.26" + "@storybook/csf" "0.0.1" + "@storybook/router" "6.0.26" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.0.26" + "@types/reach__router" "^1.3.5" + core-js "^3.0.1" + fast-deep-equal "^3.1.1" + global "^4.3.2" + lodash "^4.17.15" + memoizerific "^1.11.3" + react "^16.8.3" + regenerator-runtime "^0.13.3" + store2 "^2.7.1" + telejson "^5.0.2" + ts-dedent "^1.1.1" + util-deprecate "^1.0.2" + "@storybook/channel-postmessage@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.0.21.tgz#97e8f43c1b66f84c7b8271e447d45d4f66d355d1" @@ -3927,6 +4046,19 @@ qs "^6.6.0" telejson "^5.0.2" +"@storybook/channel-postmessage@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.0.26.tgz#a98a0132d6bdf06741afac2607e9feabe34ab98b" + integrity sha512-FT6lC8M5JlNBxPT0rYfmF1yl9mBv04nfYs82TZpp1CzpLxf7wxdCBZ8SSRmvWIVBoNwGZPDhIk5+6JWyDEISBg== + dependencies: + "@storybook/channels" "6.0.26" + "@storybook/client-logger" "6.0.26" + "@storybook/core-events" "6.0.26" + core-js "^3.0.1" + global "^4.3.2" + qs "^6.6.0" + telejson "^5.0.2" + "@storybook/channels@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.0.21.tgz#bc0951efacbaa5f8827693fba4fe7c2290b5772c" @@ -3936,6 +4068,15 @@ ts-dedent "^1.1.1" util-deprecate "^1.0.2" +"@storybook/channels@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.0.26.tgz#3e8678b4b40085081257a39b9e85fab13a19943c" + integrity sha512-H0iUorayYqS+zfhbjd+cYRzAdRLGLWUeWFu2Aa+oJ4/zeAQNL+DafWboHc567RQ4Vb5KqE5QZoCFskWUUYqJYA== + dependencies: + core-js "^3.0.1" + ts-dedent "^1.1.1" + util-deprecate "^1.0.2" + "@storybook/client-api@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.0.21.tgz#6a652dea67d219a31d18af0e05b9f17ba6c7c316" @@ -3959,6 +4100,29 @@ ts-dedent "^1.1.1" util-deprecate "^1.0.2" +"@storybook/client-api@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.0.26.tgz#ac9334ba86834e5cb23fc4fb577de60bda66164d" + integrity sha512-Qd5wR5b5lio/EchuJMhAmmJAE1pfvnEyu+JnyFGwMZLV9mN9NSspz+YsqbSCCDZsYcP5ewvPEnumIWqmj/wagQ== + dependencies: + "@storybook/addons" "6.0.26" + "@storybook/channel-postmessage" "6.0.26" + "@storybook/channels" "6.0.26" + "@storybook/client-logger" "6.0.26" + "@storybook/core-events" "6.0.26" + "@storybook/csf" "0.0.1" + "@types/qs" "^6.9.0" + "@types/webpack-env" "^1.15.2" + core-js "^3.0.1" + global "^4.3.2" + lodash "^4.17.15" + memoizerific "^1.11.3" + qs "^6.6.0" + stable "^0.1.8" + store2 "^2.7.1" + ts-dedent "^1.1.1" + util-deprecate "^1.0.2" + "@storybook/client-logger@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.0.21.tgz#20369addf9eb79fc0c85a2e0dcb48f5a1a544532" @@ -3967,6 +4131,14 @@ core-js "^3.0.1" global "^4.3.2" +"@storybook/client-logger@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.0.26.tgz#e3d28bd8dc02ec2c53a9d69773a68189590b746f" + integrity sha512-VNoL6/oehVhn3hZi9vrTNT+C/3oAZKV+smfZFnPtsCR/Fq7CKbmsBd0pGPL57f81RU8e8WygwrIlAGJTDSNIjw== + dependencies: + core-js "^3.0.1" + global "^4.3.2" + "@storybook/components@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/components/-/components-6.0.21.tgz#2f355370f993e0b7b9062094a03dffc2cdda91db" @@ -3995,6 +4167,34 @@ react-textarea-autosize "^8.1.1" ts-dedent "^1.1.1" +"@storybook/components@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/components/-/components-6.0.26.tgz#e1f6e16aae850a71c9ac7bdd1d44a068ec9cfdc1" + integrity sha512-8wigI1pDFJO1m1IQWPguOK+nOsaAVRWkVdu+2te/rDcIR9QNvMzzou0+Lhfp3zKSVT4E6mEoGB/TWXXF5Iq0sQ== + dependencies: + "@storybook/client-logger" "6.0.26" + "@storybook/csf" "0.0.1" + "@storybook/theming" "6.0.26" + "@types/overlayscrollbars" "^1.9.0" + "@types/react-color" "^3.0.1" + "@types/react-syntax-highlighter" "11.0.4" + core-js "^3.0.1" + fast-deep-equal "^3.1.1" + global "^4.3.2" + lodash "^4.17.15" + markdown-to-jsx "^6.11.4" + memoizerific "^1.11.3" + overlayscrollbars "^1.10.2" + polished "^3.4.4" + popper.js "^1.14.7" + react "^16.8.3" + react-color "^2.17.0" + react-dom "^16.8.3" + react-popper-tooltip "^2.11.0" + react-syntax-highlighter "^12.2.1" + react-textarea-autosize "^8.1.1" + ts-dedent "^1.1.1" + "@storybook/core-events@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.0.21.tgz#2ce51e6d7524e7543dbb29571beac1dbeb4e5f40" @@ -4002,6 +4202,13 @@ dependencies: core-js "^3.0.1" +"@storybook/core-events@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.0.26.tgz#61181c9a8610d26cc85d47f133a563879044ca2d" + integrity sha512-nWjS/+kMiw31OPgeJQaiFsJk9ZJJo3/d4c+kc6GOl2iC1H3Q4/5cm3NvJBn/7bUtKHmSFwfbDouj+XjUk5rZbQ== + dependencies: + core-js "^3.0.1" + "@storybook/core@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/core/-/core-6.0.21.tgz#105c2b90ab27e7b478cb1b7d10e9fe5aba5e0708" @@ -4161,6 +4368,18 @@ memoizerific "^1.11.3" qs "^6.6.0" +"@storybook/router@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.0.26.tgz#5b991001afa7d7eb5e40c53cd4c58266b6f9edfd" + integrity sha512-kQ1LF/2gX3IkjS1wX7CsoeBc9ptHQzOsyax16rUyJa769DT5vMNtFtQxjNXMqSiSapPg2yrXJFKQNaoWvKgQEQ== + dependencies: + "@reach/router" "^1.3.3" + "@types/reach__router" "^1.3.5" + core-js "^3.0.1" + global "^4.3.2" + memoizerific "^1.11.3" + qs "^6.6.0" + "@storybook/semver@^7.3.2": version "7.3.2" resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0" @@ -4203,6 +4422,24 @@ resolve-from "^5.0.0" ts-dedent "^1.1.1" +"@storybook/theming@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.0.26.tgz#e5b545fb2653dfd1b043b567197d490b1c3c0da3" + integrity sha512-9yon2ofb9a+RT1pdvn8Njydy7XRw0qXcIsMqGsJRKoZecmRRozqB6DxH9Gbdf1vRSbM9gYUUDjbiMDFz7+4RiQ== + dependencies: + "@emotion/core" "^10.0.20" + "@emotion/is-prop-valid" "^0.8.6" + "@emotion/styled" "^10.0.17" + "@storybook/client-logger" "6.0.26" + core-js "^3.0.1" + deep-object-diff "^1.1.0" + emotion-theming "^10.0.19" + global "^4.3.2" + memoizerific "^1.11.3" + polished "^3.4.4" + resolve-from "^5.0.0" + ts-dedent "^1.1.1" + "@storybook/ui@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.0.21.tgz#5dac2b68a30f5dba5457e0315f58977e07138968" @@ -4477,16 +4714,15 @@ dependencies: defer-to-connect "^2.0.0" -"@testing-library/cypress@^6.0.0": - version "6.0.1" - resolved "https://registry.npmjs.org/@testing-library/cypress/-/cypress-6.0.1.tgz#c924a69617db6a403c498abcb63759b79318fc76" - integrity sha512-hcPu2OnVuSTX1yDubEe7TBRD6mP2VgyopP1WTBIrJP79INPZdgOAX+TMNH68uZ/r5b4C7100IB4hjPIEQ+/Xog== +"@testing-library/cypress@^7.0.1": + version "7.0.1" + resolved "https://registry.npmjs.org/@testing-library/cypress/-/cypress-7.0.1.tgz#2843033acaefe96cb4cf789f16e98d957383e59d" + integrity sha512-LtggqG/7Hdc1EiKdmqXQwxWOO3ET1dkZtq0S8mIe8o+xaOtaVLrdCn0dE8Bi4Aj7z3w51w6wN9STdYymnUPlnQ== dependencies: "@babel/runtime" "^7.11.2" "@testing-library/dom" "^7.22.2" - "@types/testing-library__cypress" "^5.0.6" -"@testing-library/dom@^7.11.0", "@testing-library/dom@^7.17.1", "@testing-library/dom@^7.22.2": +"@testing-library/dom@^7.17.1", "@testing-library/dom@^7.22.2": version "7.23.0" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-7.23.0.tgz#c54c0fa53705ad867bcefb52fc0c96487fbc10f6" integrity sha512-H5m090auYH+obdZmsaYLrSWC5OauWD2CvNbz88KBxQJoXgkJzbU0DpAG8BS7Evj5WqCC3nAAKrLS6vw0ljUYLg== @@ -4498,17 +4734,16 @@ pretty-format "^26.4.2" "@testing-library/jest-dom@^5.10.1": - version "5.10.1" - resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.10.1.tgz#6508a9f007bd74e5d3c0b3135b668027ab663989" - integrity sha512-uv9lLAnEFRzwUTN/y9lVVXVXlEzazDkelJtM5u92PsGkEasmdI+sfzhZHxSDzlhZVTrlLfuMh2safMr8YmzXLg== + version "5.11.4" + resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.4.tgz#f325c600db352afb92995c2576022b35621ddc99" + integrity sha512-6RRn3epuweBODDIv3dAlWjOEHQLpGJHB2i912VS3JQtsD22+ENInhdDNl4ZZQiViLlIfFinkSET/J736ytV9sw== dependencies: "@babel/runtime" "^7.9.2" "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^4.2.2" chalk "^3.0.0" - css "^2.2.4" + css "^3.0.0" css.escape "^1.5.1" - jest-diff "^25.1.0" - jest-matcher-utils "^25.1.0" lodash "^4.17.15" redent "^3.0.0" @@ -4653,6 +4888,13 @@ dependencies: "@babel/types" "^7.3.0" +"@types/babel__traverse@^7.0.4": + version "7.0.15" + resolved "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.0.15.tgz#db9e4238931eb69ef8aab0ad6523d4d4caa39d03" + integrity sha512-Pzh9O3sTK8V6I1olsXpCfj2k/ygO2q1X0vhhnDrEQyYLHZesWz+zMZMVcwXLCYf0U36EtmyYaFGPfXlTtDHe3A== + dependencies: + "@babel/types" "^7.3.0" + "@types/body-parser@*", "@types/body-parser@1.19.0", "@types/body-parser@^1.19.0": version "1.19.0" resolved "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" @@ -4726,6 +4968,13 @@ dependencies: "@types/express" "*" +"@types/concat-stream@^1.6.0": + version "1.6.0" + resolved "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-1.6.0.tgz#394dbe0bb5fee46b38d896735e8b68ef2390d00d" + integrity sha1-OU2+C7X+5Gs42JZzXoto7yOQ0A0= + dependencies: + "@types/node" "*" + "@types/connect-history-api-fallback@*": version "1.3.3" resolved "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.3.tgz#4772b79b8b53185f0f4c9deab09236baf76ee3b4" @@ -4792,16 +5041,38 @@ dependencies: postcss "5 - 7" +"@types/d3-color@*": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.0.tgz#febdfadade56e215a4c3f612fe3000d92999f5d5" + integrity sha512-Bs0maTeU47rdZT+n42iQ0C4gnbnJlIDJkqHFtIsDx2tPPITDeoSdIrm+00UYXzegzArYC2GsG80eHNMwz08IAw== + "@types/d3-force@^1.2.1": version "1.2.1" resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-1.2.1.tgz#c28803ea36fe29788db69efa0ad6c2dc09544e83" integrity sha512-jqK+I36uz4kTBjyk39meed5y31Ab+tXYN/x1dn3nZEus9yOHCLc+VrcIYLc/aSQ0Y7tMPRlIhLetulME76EiiA== +"@types/d3-interpolate@*": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.0.tgz#325029216dc722c1c68c33ccda759f1209d35823" + integrity sha512-Wt1v2zTlEN8dSx8hhx6MoOhWQgTkz0Ukj7owAEIOF2QtI0e219paFX9rf/SLOr/UExWb1TcUzatU8zWwFby6gg== + dependencies: + "@types/d3-color" "*" + "@types/d3-path@*": version "1.0.8" resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.8.tgz#48e6945a8ff43ee0a1ce85c8cfa2337de85c7c79" integrity sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA== +"@types/d3-path@^1": + version "1.0.9" + resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz#73526b150d14cd96e701597cbf346cfd1fd4a58c" + integrity sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ== + +"@types/d3-selection@*", "@types/d3-selection@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.0.tgz#59df94a8e47ed1050a337d4ffb4d4d213aa590a8" + integrity sha512-EF0lWZ4tg7oDFg4YQFlbOU3936e3a9UmoQ2IXlBy1+cv2c2Pv7knhKUzGlH5Hq2sF/KeDTH1amiRPey2rrLMQA== + "@types/d3-shape@*": version "1.3.2" resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.2.tgz#a41d9d6b10d02e221696b240caf0b5d0f5a588ec" @@ -4809,6 +5080,26 @@ dependencies: "@types/d3-path" "*" +"@types/d3-shape@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.0.0.tgz#61aa065726f3c2641aedc59c3603475ab11aeb2f" + integrity sha512-NLzD02m5PiD1KLEDjLN+MtqEcFYn4ZL9+Rqc9ZwARK1cpKZXd91zBETbe6wpBB6Ia0D0VZbpmbW3+BsGPGnCpA== + dependencies: + "@types/d3-path" "^1" + +"@types/d3-zoom@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.0.tgz#ef8b87464e8ebc7c66b70f6383d1ae841e78e7fc" + integrity sha512-daL0PJm4yT0ISTGa7p2lHX0kvv9FO/IR1ooWbHR/7H4jpbaKiLux5FslyS/OvISPiJ5SXb4sOqYhO6fMB6hKRw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/dagre@^0.7.44": + version "0.7.44" + resolved "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.44.tgz#8f4b796b118ca29c132da7068fbc0d0351ee5851" + integrity sha512-N6HD+79w77ZVAaVO7JJDW5yJ9LAxM62FpgNGO9xEde+KVYjDRyhIMzfiErXpr1g0JPon9kwlBzoBK6s4fOww9Q== + "@types/diff@^4.0.2": version "4.0.2" resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c" @@ -4877,6 +5168,13 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^9.0.3": + version "9.0.3" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.3.tgz#9996e5cce993508c32325380b429f04a1327523e" + integrity sha512-NKdGoXLTFTRED3ENcfCsH8+ekV4gbsysanx2OPbstXVV6fZMgUCqTxubs6I9r7pbOJbFgVq1rpFtLURjKCZWUw== + dependencies: + "@types/node" "*" + "@types/git-url-parse@^9.0.0": version "9.0.0" resolved "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" @@ -5043,6 +5341,14 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" +"@types/jest@26.x": + version "26.0.15" + resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe" + integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog== + dependencies: + jest-diff "^26.0.0" + pretty-format "^26.0.0" + "@types/jquery@^3.3.34": version "3.5.1" resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.1.tgz#cebb057acf5071c40e439f30e840c57a30d406c3" @@ -5060,7 +5366,14 @@ resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz#136d5e6a57a931e1cce6f9d8126aa98a9c92a6bb" integrity sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww== -"@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5": +"@types/json-schema-merge-allof@^0.6.0": + version "0.6.0" + resolved "https://registry.npmjs.org/@types/json-schema-merge-allof/-/json-schema-merge-allof-0.6.0.tgz#0f587d8a3bcb41a55ef2e91d3ba96430c9bc1813" + integrity sha512-v6iCEk4Sxy1twlCTtrRxMqSHX0vuLJ7Ql4hiIUZRMOswxtlUeybIfYaZIj7pX747RBczG2YtBm4Fn6sqKzeY2Q== + dependencies: + "@types/json-schema" "*" + +"@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6": version "7.0.6" resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0" integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw== @@ -5143,6 +5456,13 @@ dependencies: "@types/react" "*" +"@types/mdast@^3.0.0", "@types/mdast@^3.0.3": + version "3.0.3" + resolved "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb" + integrity sha512-SXPBMnFVQg1s00dlMCc/jCdvPqdE4mXaMMCeRlxLDmTAEoegHT53xKtkDnzDTOcmMHUfcjyf36/YYZ6SxRdnsw== + dependencies: + "@types/unist" "*" + "@types/micromatch@^4.0.1": version "4.0.1" resolved "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7" @@ -5167,6 +5487,11 @@ resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/minimist@^1.2.0": + version "1.2.0" + resolved "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" + integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY= + "@types/minipass@*": version "2.2.0" resolved "https://registry.npmjs.org/@types/minipass/-/minipass-2.2.0.tgz#51ad404e8eb1fa961f75ec61205796807b6f9651" @@ -5181,6 +5506,13 @@ dependencies: "@types/node" "*" +"@types/mock-fs@^4.13.0": + version "4.13.0" + resolved "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.0.tgz#b8b01cd2db588668b2532ecd21b1babd3fffb2c0" + integrity sha512-FUqxhURwqFtFBCuUj3uQMp7rPSQs//b3O9XecAVxhqS9y4/W8SIJEZFq2mmpnFVZBXwR/2OyPLE97CpyYiB8Mw== + dependencies: + "@types/node" "*" + "@types/morgan@^1.9.0": version "1.9.1" resolved "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.1.tgz#6457872df95647c1dbc6b3741e8146b71ece74bf" @@ -5188,7 +5520,7 @@ dependencies: "@types/node" "*" -"@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.4", "@types/node-fetch@^2.5.7": +"@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.4": version "2.5.7" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== @@ -5201,22 +5533,12 @@ resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c" integrity sha512-W+fpe5s91FBGE0pEa0lnqGLL4USgpLgs4nokw16SrBBco/gQxuua7KnArSEOd5iaMqbbSHV10vUDkJYJJqpXKA== -"@types/node@^10.1.0": - version "10.17.28" - resolved "https://registry.npmjs.org/@types/node/-/node-10.17.28.tgz#0e36d718a29355ee51cec83b42d921299200f6d9" - integrity sha512-dzjES1Egb4c1a89C7lKwQh8pwjYmlOAG9dW1pBgxEk57tMrLnssOfEthz8kdkNaBd7lIqQx7APm5+mZ619IiCQ== - -"@types/node@^10.12.0": +"@types/node@^10.1.0", "@types/node@^10.12.0": version "10.17.35" resolved "https://registry.npmjs.org/@types/node/-/node-10.17.35.tgz#58058f29b870e6ae57b20e4f6e928f02b7129f56" integrity sha512-gXx7jAWpMddu0f7a+L+txMplp3FnHl53OhQIF9puXKq3hDGY/GjH+MF04oWnV/adPSCrbtHumDCFwzq2VhltWA== -"@types/node@^12.0.0": - version "12.12.53" - resolved "https://registry.npmjs.org/@types/node/-/node-12.12.53.tgz#be0d375933c3d15ef2380dafb3b0350ea7021129" - integrity sha512-51MYTDTyCziHb70wtGNFRwB4l+5JNvdqzFSkbDvpbftEgVUBEE+T5f7pROhWMp/fxp07oNIEQZd5bbfAH22ohQ== - -"@types/node@^12.7.1": +"@types/node@^12.0.0", "@types/node@^12.7.1": version "12.12.58" resolved "https://registry.npmjs.org/@types/node/-/node-12.12.58.tgz#46dae9b2b9ee5992818c8f7cee01ff4ce03ab44c" integrity sha512-Be46CNIHWAagEfINOjmriSxuv7IVcqbGe+sDSg2SYCEz/0CRBy7LRASGfRbD8KZkqoePU73Wsx3UvOSFcq/9hA== @@ -5226,10 +5548,10 @@ resolved "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" integrity sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw== -"@types/nodegit@0.26.8": - version "0.26.8" - resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.8.tgz#81a79307fbdd1cf028a8ec7cbc94020bac54be6f" - integrity sha512-CwcynPazar6cJxjn8PG2RIujNgFvH2pAoA/bBBvPTUrONVzc9hj0DRMYzioy7zI/+e8TfJJLP+DK26mEU0eErg== +"@types/nodegit@0.26.11": + version "0.26.11" + resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.11.tgz#0cbc5e929f23e5ffc536920e3a887e0b3f46d1a4" + integrity sha512-BGrY9F8lBtfU+Ne1Pjb9k/PUsfSCUAqPXgKkTkM6mc5213H5VAoM12zG/sz/cr3jI3AXcngNbAYWlqSrXsWJug== dependencies: "@types/node" "*" @@ -5310,9 +5632,9 @@ "@types/passport" "*" "@types/passport@*", "@types/passport@^1.0.3": - version "1.0.3" - resolved "https://registry.npmjs.org/@types/passport/-/passport-1.0.3.tgz#e459ed6c262bf0686684d1b05901be0d0b192a9c" - integrity sha512-nyztuxtDPQv9utCzU0qW7Gl8BY2Dn8BKlYAFFyxKipFxjaVd96celbkLCV/tRqqBUZ+JB8If3UfgV8347DTo3Q== + version "1.0.4" + resolved "https://registry.npmjs.org/@types/passport/-/passport-1.0.4.tgz#1b35c4e197560d3974fa5f71711b6e9cce0711f0" + integrity sha512-h5OfAbfBBYSzjeU0GTuuqYEk9McTgWeGQql9g3gUw2/NNCfD7VgExVRYJVVeU13Twn202Mvk9BT0bUrl30sEgA== dependencies: "@types/express" "*" @@ -5467,6 +5789,11 @@ dependencies: "@types/node" "*" +"@types/regression@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/regression/-/regression-2.0.0.tgz#0677ea78d7bdb37039c02ebbccf062042f756ae3" + integrity sha512-Ch2FD53M1HpFLL6zSTc/sfuyqQcIPy+/PV3xFT6QYtk9EOiMI29XOYmLNxBb1Y0lfMOR/NNa86J1gRc/1jGLyw== + "@types/relateurl@*": version "0.2.28" resolved "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6" @@ -5518,13 +5845,6 @@ resolved "https://registry.npmjs.org/@types/semver/-/semver-6.2.1.tgz#a236185670a7860f1597cf73bea2e16d001461ba" integrity sha512-+beqKQOh9PYxuHvijhVl+tIHvT6tuwOrE9m14zd+MT2A38KoKZhh7pYJ0SNleLtwDsiIxHDsIk9bv01oOxvSvA== -"@types/serve-handler@^6.1.0": - version "6.1.0" - resolved "https://registry.npmjs.org/@types/serve-handler/-/serve-handler-6.1.0.tgz#6952aaf864e542297ce8a2480b3e0d9ea59de7f6" - integrity sha512-F9BpWotLZrQvq1CdE3oCnVmQUb6dWq20HSB/qjF4mG7WkZjPQ6fye8veznMKoBPhFJve4yz9C1NOITdTcoLngQ== - dependencies: - "@types/node" "*" - "@types/serve-static@*": version "1.13.3" resolved "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1" @@ -5548,10 +5868,10 @@ resolved "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== -"@types/stack-utils@^1.0.1": - version "1.0.1" - resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" - integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== +"@types/stack-utils@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" + integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw== "@types/start-server-webpack-plugin@^2.2.0": version "2.2.0" @@ -5623,14 +5943,6 @@ dependencies: "@types/estree" "*" -"@types/testing-library__cypress@^5.0.6": - version "5.0.6" - resolved "https://registry.npmjs.org/@types/testing-library__cypress/-/testing-library__cypress-5.0.6.tgz#9015f575c1a98f05996a4fe769071134ee488c26" - integrity sha512-TUp5wfanU7zUZigKqIeQDChnHQ1MEzbYqrI5iCQMFiesWNOASWm/el1lFBh1JPqmd6GkdDdDiHYJnkqd9le2ww== - dependencies: - "@testing-library/dom" "^7.11.0" - cypress "*" - "@types/testing-library__jest-dom@^5.9.1": version "5.9.1" resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.1.tgz#aba5ee062b7880f69c212ef769389f30752806e5" @@ -5676,6 +5988,11 @@ resolved "https://registry.npmjs.org/@types/underscore/-/underscore-1.10.23.tgz#cc672e8864000d288e1e39c609fd9cab84391ff3" integrity sha512-vX1NPekXhrLquFWskH2thcvFAha187F/lM6xYOoEMZWwJ/6alSk0/ttmGP/YRqcqtCv0TMbZjYAdZyHAEcuU4g== +"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3": + version "2.0.3" + resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" + integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== + "@types/uuid@^8.0.0": version "8.0.0" resolved "https://registry.npmjs.org/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0" @@ -5714,9 +6031,9 @@ source-map "^0.6.1" "@types/webpack@*", "@types/webpack@^4.41.7", "@types/webpack@^4.41.8": - version "4.41.21" - resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.21.tgz#cc685b332c33f153bb2f5fc1fa3ac8adeb592dee" - integrity sha512-2j9WVnNrr/8PLAB5csW44xzQSJwS26aOnICsP3pSGCEdsu6KYtfQ6QJsVUKHWRnm1bL7HziJsfh5fHqth87yKA== + version "4.41.22" + resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.22.tgz#ff9758a17c6bd499e459b91e78539848c32d0731" + integrity sha512-JQDJK6pj8OMV9gWOnN1dcLCyU9Hzs6lux0wBO4lr1+gyEhIBR9U3FMrz12t2GPkg110XAxEAw2WHF6g7nZIbRQ== dependencies: "@types/anymatch" "*" "@types/node" "*" @@ -5770,59 +6087,123 @@ dependencies: "@types/yargs-parser" "*" -"@types/yup@^0.28.2": - version "0.28.3" - resolved "https://registry.npmjs.org/@types/yup/-/yup-0.28.3.tgz#387c35f9a6a36b8d3561f6601eb4e72518b92899" - integrity sha512-0Sir2LxOmupF8HBUvpJoZghLmOqKfZsBk1GYlMwSIccLDDUoN04LHvo0KzDp9qxt1IKf9Fudpj35SrJ8VqetkQ== +"@types/yup@^0.29.8": + version "0.29.8" + resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.8.tgz#83db15735987db9fe5a38772a0fb9500e3c5bf39" + integrity sha512-MBSp62AjB1KrSOI3gX9GekddXU5YYQAVA93+aSl78biBqoSzxg876aQY2KJK5Gnfbpqq7O2cadVX5kPAtBqIXw== "@types/zen-observable@^0.8.0": version "0.8.0" resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== -"@typescript-eslint/eslint-plugin@^2.14.0": - version "2.24.0" - resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.24.0.tgz#a86cf618c965a462cddf3601f594544b134d6d68" - integrity sha512-wJRBeaMeT7RLQ27UQkDFOu25MqFOBus8PtOa9KaT5ZuxC1kAsd7JEHqWt4YXuY9eancX0GK9C68i5OROnlIzBA== +"@typescript-eslint/eslint-plugin@^v3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz#7e061338a1383f59edc204c605899f93dc2e2c8f" + integrity sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ== dependencies: - "@typescript-eslint/experimental-utils" "2.24.0" - eslint-utils "^1.4.3" + "@typescript-eslint/experimental-utils" "3.10.1" + debug "^4.1.1" functional-red-black-tree "^1.0.1" regexpp "^3.0.0" + semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@2.24.0", "@typescript-eslint/experimental-utils@^2.5.0": - version "2.24.0" - resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.24.0.tgz#a5cb2ed89fedf8b59638dc83484eb0c8c35e1143" - integrity sha512-DXrwuXTdVh3ycNCMYmWhUzn/gfqu9N0VzNnahjiDJvcyhfBy4gb59ncVZVxdp5XzBC77dCncu0daQgOkbvPwBw== +"@typescript-eslint/experimental-utils@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz#e179ffc81a80ebcae2ea04e0332f8b251345a686" + integrity sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/typescript-estree" "2.24.0" + "@typescript-eslint/types" "3.10.1" + "@typescript-eslint/typescript-estree" "3.10.1" eslint-scope "^5.0.0" + eslint-utils "^2.0.0" -"@typescript-eslint/parser@^2.14.0": - version "2.24.0" - resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-2.24.0.tgz#2cf0eae6e6dd44d162486ad949c126b887f11eb8" - integrity sha512-H2Y7uacwSSg8IbVxdYExSI3T7uM1DzmOn2COGtCahCC3g8YtM1xYAPi2MAHyfPs61VKxP/J/UiSctcRgw4G8aw== +"@typescript-eslint/experimental-utils@^4.0.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.4.1.tgz#40613b9757fa0170de3e0043254dbb077cafac0c" + integrity sha512-Nt4EVlb1mqExW9cWhpV6pd1a3DkUbX9DeyYsdoeziKOpIJ04S2KMVDO+SEidsXRH/XHDpbzXykKcMTLdTXH6cQ== + dependencies: + "@types/json-schema" "^7.0.3" + "@typescript-eslint/scope-manager" "4.4.1" + "@typescript-eslint/types" "4.4.1" + "@typescript-eslint/typescript-estree" "4.4.1" + eslint-scope "^5.0.0" + eslint-utils "^2.0.0" + +"@typescript-eslint/parser@^v3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz#1883858e83e8b442627e1ac6f408925211155467" + integrity sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw== dependencies: "@types/eslint-visitor-keys" "^1.0.0" - "@typescript-eslint/experimental-utils" "2.24.0" - "@typescript-eslint/typescript-estree" "2.24.0" + "@typescript-eslint/experimental-utils" "3.10.1" + "@typescript-eslint/types" "3.10.1" + "@typescript-eslint/typescript-estree" "3.10.1" eslint-visitor-keys "^1.1.0" -"@typescript-eslint/typescript-estree@2.24.0": - version "2.24.0" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.24.0.tgz#38bbc8bb479790d2f324797ffbcdb346d897c62a" - integrity sha512-RJ0yMe5owMSix55qX7Mi9V6z2FDuuDpN6eR5fzRJrp+8in9UF41IGNQHbg5aMK4/PjVaEQksLvz0IA8n+Mr/FA== +"@typescript-eslint/scope-manager@4.4.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.4.1.tgz#d19447e60db2ce9c425898d62fa03b2cce8ea3f9" + integrity sha512-2oD/ZqD4Gj41UdFeWZxegH3cVEEH/Z6Bhr/XvwTtGv66737XkR4C9IqEkebCuqArqBJQSj4AgNHHiN1okzD/wQ== dependencies: + "@typescript-eslint/types" "4.4.1" + "@typescript-eslint/visitor-keys" "4.4.1" + +"@typescript-eslint/types@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" + integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== + +"@typescript-eslint/types@4.4.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.4.1.tgz#c507b35cf523bc7ba00aae5f75ee9b810cdabbc1" + integrity sha512-KNDfH2bCyax5db+KKIZT4rfA8rEk5N0EJ8P0T5AJjo5xrV26UAzaiqoJCxeaibqc0c/IvZxp7v2g3difn2Pn3w== + +"@typescript-eslint/typescript-estree@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz#fd0061cc38add4fad45136d654408569f365b853" + integrity sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w== + dependencies: + "@typescript-eslint/types" "3.10.1" + "@typescript-eslint/visitor-keys" "3.10.1" debug "^4.1.1" - eslint-visitor-keys "^1.1.0" glob "^7.1.6" is-glob "^4.0.1" lodash "^4.17.15" - semver "^6.3.0" + semver "^7.3.2" tsutils "^3.17.1" +"@typescript-eslint/typescript-estree@4.4.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.4.1.tgz#598f6de488106c2587d47ca2462c60f6e2797cb8" + integrity sha512-wP/V7ScKzgSdtcY1a0pZYBoCxrCstLrgRQ2O9MmCUZDtmgxCO/TCqOTGRVwpP4/2hVfqMz/Vw1ZYrG8cVxvN3g== + dependencies: + "@typescript-eslint/types" "4.4.1" + "@typescript-eslint/visitor-keys" "4.4.1" + debug "^4.1.1" + globby "^11.0.1" + is-glob "^4.0.1" + lodash "^4.17.15" + semver "^7.3.2" + tsutils "^3.17.1" + +"@typescript-eslint/visitor-keys@3.10.1": + version "3.10.1" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz#cd4274773e3eb63b2e870ac602274487ecd1e931" + integrity sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ== + dependencies: + eslint-visitor-keys "^1.1.0" + +"@typescript-eslint/visitor-keys@4.4.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.4.1.tgz#1769dc7a9e2d7d2cfd3318b77ed8249187aed5c3" + integrity sha512-H2JMWhLaJNeaylSnMSQFEhT/S/FsJbebQALmoJxMPMxLtlVAMy2uJP/Z543n9IizhjRayLSqoInehCeNW9rWcw== + dependencies: + "@typescript-eslint/types" "4.4.1" + eslint-visitor-keys "^2.0.0" + "@webassemblyjs/ast@1.9.0": version "1.9.0" resolved "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" @@ -6083,11 +6464,16 @@ acorn@^6.0.1, acorn@^6.4.1: resolved "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== -acorn@^7.1.1, acorn@^7.2.0: +acorn@^7.1.1: version "7.3.1" resolved "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== +acorn@^7.4.0: + version "7.4.1" + resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -6162,37 +6548,7 @@ ajv-keywords@^3.1.0, ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@6.5.2: - version "6.5.2" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.5.2.tgz#678495f9b82f7cca6be248dd92f59bff5e1f4360" - integrity sha512-hOs7GfvI6tUI1LfZddH82ky6mOMyTuY0mk7kE2pWpmhhUSkumzaTO5vbVwij39MdwPQWCV4Zv57Eo06NtL/GVA== - dependencies: - fast-deep-equal "^2.0.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.1" - -ajv@^5.0.0: - version "5.5.2" - resolved "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" - integrity sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU= - dependencies: - co "^4.6.0" - fast-deep-equal "^1.0.0" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.3.0" - -ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.5.5, ajv@^6.7.0: - version "6.12.4" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" - integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@^6.12.5: +ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.12.5, ajv@^6.5.5, ajv@^6.7.0: version "6.12.5" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.5.tgz#19b0e8bae8f476e5ba666300387775fb1a00a4da" integrity sha512-lRF8RORchjpKG50/WFf8xmg7sgCLFiYNNnqdKflk63whMQcWR5ngGjiSXkL9bjxy6B2npOK2HSMN49jEBMSkag== @@ -6221,7 +6577,7 @@ ansi-align@^3.0.0: dependencies: string-width "^3.0.0" -ansi-colors@^3.0.0, ansi-colors@^3.2.1: +ansi-colors@^3.0.0: version "3.2.4" resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== @@ -6855,7 +7211,7 @@ aws4@^1.8.0: resolved "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug== -axios@^0.19.0, axios@^0.19.2: +axios@^0.19.2: version "0.19.2" resolved "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== @@ -6927,16 +7283,16 @@ babel-helper-to-multiple-sequence-expressions@^0.5.0: resolved "https://registry.npmjs.org/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d" integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA== -babel-jest@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-26.3.0.tgz#10d0ca4b529ca3e7d1417855ef7d7bd6fc0c3463" - integrity sha512-sxPnQGEyHAOPF8NcUsD0g7hDCnvLL2XyblRBcgrzTWBB/mAIpWow3n1bEL+VghnnZfreLhFSBsFluRoK2tRK4g== +babel-jest@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-26.5.2.tgz#164f367a35946c6cf54eaccde8762dec50422250" + integrity sha512-U3KvymF3SczA3vOL/cgiUFOznfMET+XDIXiWnoJV45siAp2pLMG8i2+/MGZlAC3f/F6Q40LR4M4qDrWZ9wkK8A== dependencies: - "@jest/transform" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/transform" "^26.5.2" + "@jest/types" "^26.5.2" "@types/babel__core" "^7.1.7" babel-plugin-istanbul "^6.0.0" - babel-preset-jest "^26.3.0" + babel-preset-jest "^26.5.0" chalk "^4.0.0" graceful-fs "^4.2.4" slash "^3.0.0" @@ -6991,10 +7347,10 @@ babel-plugin-istanbul@^6.0.0: istanbul-lib-instrument "^4.0.0" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^26.2.0: - version "26.2.0" - resolved "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.2.0.tgz#bdd0011df0d3d513e5e95f76bd53b51147aca2dd" - integrity sha512-B/hVMRv8Nh1sQ1a3EY8I0n4Y1Wty3NrR5ebOyVT302op+DOAau+xNEImGMsUWOC3++ZlMooCytKz+NgN8aKGbA== +babel-plugin-jest-hoist@^26.5.0: + version "26.5.0" + resolved "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.5.0.tgz#3916b3a28129c29528de91e5784a44680db46385" + integrity sha512-ck17uZFD3CDfuwCLATWZxkkuGGFhMij8quP8CNhwj8ek1mqFgbFzRJ30xwC04LLscj/aKsVFfRST+b5PT7rSuw== dependencies: "@babel/template" "^7.3.3" "@babel/types" "^7.3.3" @@ -7219,12 +7575,12 @@ babel-preset-fbjs@^3.3.0: "@babel/plugin-transform-template-literals" "^7.0.0" babel-plugin-syntax-trailing-function-commas "^7.0.0-beta.0" -babel-preset-jest@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.3.0.tgz#ed6344506225c065fd8a0b53e191986f74890776" - integrity sha512-5WPdf7nyYi2/eRxCbVrE1kKCWxgWY4RsPEbdJWFm7QsesFGqjdkyLeu1zRkwM1cxK6EPIlNd6d2AxLk7J+t4pw== +babel-preset-jest@^26.5.0: + version "26.5.0" + resolved "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.5.0.tgz#f1b166045cd21437d1188d29f7fba470d5bdb0e7" + integrity sha512-F2vTluljhqkiGSJGBg/jOruA8vIIIL11YrxRcO7nviNTMbbofPSHwnm8mgP7d/wS7wRSexRoI6X1A6T74d4LQA== dependencies: - babel-plugin-jest-hoist "^26.2.0" + babel-plugin-jest-hoist "^26.5.0" babel-preset-current-node-syntax "^0.1.3" "babel-preset-minify@^0.5.0 || 0.6.0-alpha.5": @@ -8148,11 +8504,11 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: safe-buffer "^5.0.1" circleci-api@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/circleci-api/-/circleci-api-4.0.0.tgz#d773fe68f4a59e1968881269883a23b0805b3546" - integrity sha512-D/THFyhOv6THSkYXJhrOLIOmV7fmyDqgs1+pBFMAqDR+ywXszxa2Dqx1Zw+YD3O2zD2y5LQOifCDT96VidRG7Q== + version "4.1.3" + resolved "https://registry.npmjs.org/circleci-api/-/circleci-api-4.1.3.tgz#cafd80bc1905c6064724636741b4b5b231ed34d0" + integrity sha512-Km6H8LB3nNbFUjErhI+sNQrcHcU7dCBnQEi4ckBtQMkCXh+2gDvaYchfkeuIaA1hkakQT5sLmdPbYzhKbvsOMg== dependencies: - axios "^0.19.0" + axios "^0.20.0" class-utils@^0.3.5: version "0.3.6" @@ -8392,11 +8748,6 @@ codeowners-utils@^1.0.2: ignore "^5.1.4" locate-path "^5.0.0" -collapse-white-space@^1.0.2: - version "1.0.6" - resolved "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz#e63629c0016665792060dbbeb79c42239d2c5287" - integrity sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ== - collect-v8-coverage@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.0.tgz#150ee634ac3650b71d9c985eb7f608942334feb1" @@ -8506,6 +8857,11 @@ command-exists-promise@^2.0.2: resolved "https://registry.npmjs.org/command-exists-promise/-/command-exists-promise-2.0.2.tgz#7beecc4b218299f3c61fa69a4047aa0b36a64a99" integrity sha512-T6PB6vdFrwnHXg/I0kivM3DqaCGZLjjYSOe0a5WgFKcz1sOnmOeIjnhQPXVXX3QjVbLyTJ85lJkX6lUpukTzaA== +command-exists@^1.2.9: + version "1.2.9" + resolved "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" + integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== + commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -8522,9 +8878,9 @@ commander@^5.0.0, commander@^5.1.0: integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== commander@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/commander/-/commander-6.1.0.tgz#f8d722b78103141006b66f4c7ba1e97315ba75bc" - integrity sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA== + version "6.2.0" + resolved "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75" + integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== common-tags@1.8.0, common-tags@^1.8.0: version "1.8.0" @@ -8699,11 +9055,6 @@ contains-path@^0.1.0: resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= -content-disposition@0.5.2: - version "0.5.2" - resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" - integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= - content-disposition@0.5.3: version "0.5.3" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" @@ -8972,7 +9323,7 @@ cross-env@^7.0.0: dependencies: cross-spawn "^7.0.1" -cross-fetch@3.0.6, cross-fetch@^3.0.4, cross-fetch@^3.0.5: +cross-fetch@3.0.6, cross-fetch@^3.0.5, cross-fetch@^3.0.6: version "3.0.6" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== @@ -9178,15 +9529,14 @@ css.escape@1.5.1, css.escape@^1.5.1: resolved "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= -css@^2.2.4: - version "2.2.4" - resolved "https://registry.npmjs.org/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929" - integrity sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw== +css@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d" + integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ== dependencies: - inherits "^2.0.3" + inherits "^2.0.4" source-map "^0.6.1" - source-map-resolve "^0.5.2" - urix "^0.1.0" + source-map-resolve "^0.6.0" cssesc@^3.0.0: version "3.0.0" @@ -9339,7 +9689,7 @@ cyclist@^1.0.1: resolved "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -cypress@*, cypress@^4.2.0: +cypress@^4.2.0: version "4.12.1" resolved "https://registry.npmjs.org/cypress/-/cypress-4.12.1.tgz#0ead1b9f4c0917d69d8b57f996b6e01fe693b6ec" integrity sha512-9SGIPEmqU8vuRA6xst2CMTYd9sCFCxKSzrHt0wr+w2iAQMCIIsXsQ5Gplns1sT6LDbZcmLv6uehabAOl3fhc9Q== @@ -9397,11 +9747,29 @@ d3-color@1: resolved "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a" integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== +"d3-color@1 - 2": + version "2.0.0" + resolved "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" + integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== + "d3-dispatch@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz#8a18e16f76dd3fcaef42163c97b926aa9b55e7cf" integrity sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA== +d3-drag@2: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz#9eaf046ce9ed1c25c88661911c1d5a4d8eb7ea6d" + integrity sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w== + dependencies: + d3-dispatch "1 - 2" + d3-selection "2" + +"d3-ease@1 - 2": + version "2.0.0" + resolved "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz#fd1762bfca00dae4bacea504b1d628ff290ac563" + integrity sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ== + d3-force@^2.0.1: version "2.1.1" resolved "https://registry.npmjs.org/d3-force/-/d3-force-2.1.1.tgz#f20ccbf1e6c9e80add1926f09b51f686a8bc0937" @@ -9423,11 +9791,23 @@ d3-interpolate@1, d3-interpolate@^1.3.0: dependencies: d3-color "1" +"d3-interpolate@1 - 2": + version "2.0.1" + resolved "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" + integrity sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ== + dependencies: + d3-color "1 - 2" + d3-path@1: version "1.0.9" resolved "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== +"d3-path@1 - 2": + version "2.0.0" + resolved "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz#55d86ac131a0548adae241eebfb56b4582dd09d8" + integrity sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA== + "d3-quadtree@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz#edbad045cef88701f6fee3aee8e93fb332d30f9d" @@ -9445,6 +9825,11 @@ d3-scale@^2.1.0: d3-time "1" d3-time-format "2" +d3-selection@2, d3-selection@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz#94a11638ea2141b7565f883780dabc7ef6a61066" + integrity sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA== + d3-shape@^1.2.0: version "1.3.7" resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" @@ -9452,6 +9837,13 @@ d3-shape@^1.2.0: dependencies: d3-path "1" +d3-shape@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-2.0.0.tgz#2331b62fa784a2a1daac47a7233cfd69301381fd" + integrity sha512-djpGlA779ua+rImicYyyjnOjeubyhql1Jyn1HK0bTyawuH76UQRWXd+pftr67H6Fa8hSwetkgb/0id3agKWykw== + dependencies: + d3-path "1 - 2" + d3-time-format@2: version "2.3.0" resolved "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850" @@ -9469,6 +9861,28 @@ d3-time@1: resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6" integrity sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA== +d3-transition@2: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz#366ef70c22ef88d1e34105f507516991a291c94c" + integrity sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog== + dependencies: + d3-color "1 - 2" + d3-dispatch "1 - 2" + d3-ease "1 - 2" + d3-interpolate "1 - 2" + d3-timer "1 - 2" + +d3-zoom@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz#f04d0afd05518becce879d04709c47ecd93fba54" + integrity sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw== + dependencies: + d3-dispatch "1 - 2" + d3-drag "2" + d3-interpolate "1 - 2" + d3-selection "2" + d3-transition "2" + d@1, d@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" @@ -9477,6 +9891,14 @@ d@1, d@^1.0.1: es5-ext "^0.10.50" type "^1.0.1" +dagre@^0.8.5: + version "0.8.5" + resolved "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz#ba30b0055dac12b6c1fcc247817442777d06afee" + integrity sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw== + dependencies: + graphlib "^2.1.8" + lodash "^4.17.15" + damerau-levenshtein@^1.0.4: version "1.0.6" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791" @@ -9539,6 +9961,11 @@ dateformat@^3.0.0: resolved "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== +dayjs@^1.9.4: + version "1.9.4" + resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.9.4.tgz#fcde984e227f4296f04e7b05720adad2e1071f1b" + integrity sha512-ABSF3alrldf7nM9sQ2U+Ln67NRwmzlLOqG7kK03kck0mw3wlSSEKv/XhKGGxUjQcS57QeiCyNdrFgtj9nWlrng== + de-indent@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" @@ -9563,7 +9990,14 @@ debug@3.1.0, debug@=3.1.0: dependencies: ms "2.0.0" -debug@4, debug@4.1.1, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: +debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1" + integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg== + dependencies: + ms "2.1.2" + +debug@4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== @@ -9577,13 +10011,6 @@ debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.5, debug@^3.2.6: dependencies: ms "^2.1.1" -debug@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1" - integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg== - dependencies: - ms "2.1.2" - debuglog@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -9667,11 +10094,6 @@ deep-extend@0.6.0, deep-extend@^0.6.0, deep-extend@~0.6.0: resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== -deep-freeze@^0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz#3a0b0005de18672819dfd38cd31f91179c893e84" - integrity sha1-OgsABd4YZygZ39OM0x+RF5yJPoQ= - deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.3" resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" @@ -9687,11 +10109,6 @@ deepmerge@4.2.2, deepmerge@^4.2.2: resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== -default-branch@^1.0.8: - version "1.0.8" - resolved "https://registry.npmjs.org/default-branch/-/default-branch-1.0.8.tgz#0e2f36a90e3b0d9f73cdf8e02841364ed35b8b54" - integrity sha512-pViUZEnaxd/Hbu880MEXF7XqV8RJ1ssDlkvzx+woQhcKW8px3BrVDvwBGn09zRiKZ+gOLipK7ft5x3no+3vb8A== - default-gateway@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" @@ -9901,10 +10318,10 @@ diff-sequences@^25.2.6: resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd" integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg== -diff-sequences@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.3.0.tgz#62a59b1b29ab7fd27cef2a33ae52abe73042d0a2" - integrity sha512-5j5vdRcw3CNctePNYN0Wy2e/JbWT6cAYnXv5OuqPhDpyCGc0uLu2TK0zOCJWNB9kOIfYMSpIulRaDgIi4HJ6Ig== +diff-sequences@^26.5.0: + version "26.5.0" + resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd" + integrity sha512-ZXx86srb/iYy6jG71k++wBN9P9J05UNQ5hQHQd9MtMPvcqXPx/vKU69jfHV637D00Q2gSgPk2D+jSx3l1lDW/Q== diff@^4.0.1, diff@^4.0.2: version "4.0.2" @@ -10085,11 +10502,6 @@ domhandler@^3.0, domhandler@^3.0.0: dependencies: domelementtype "^2.0.1" -dompurify@^1.0.11: - version "1.0.11" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-1.0.11.tgz#fe0f4a40d147f7cebbe31a50a1357539cfc1eb4d" - integrity sha512-XywCTXZtc/qCX3iprD1pIklRVk/uhl8BKpkTxr+ZyMVUzSUg7wkQXRBp/euJ5J5moa1QvfpvaPQVP71z1O59dQ== - dompurify@^2.0.12: version "2.1.1" resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.1.1.tgz#b5aa988676b093a9c836d8b855680a8598af25fe" @@ -10100,6 +10512,11 @@ dompurify@^2.0.7: resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.0.12.tgz#284a2b041e1c60b8e72d7b4d2fadad36141254ae" integrity sha512-Fl8KseK1imyhErHypFPA8qpq9gPzlsJ/EukA6yk9o0gX23p1TzC+rh9LqNg1qvErRTc0UNMYlKxEGSfSh43NDg== +dompurify@^2.1.1: + version "2.2.0" + resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.2.0.tgz#51d34e76faa38b5d6b4e83a0678530f27fe3965c" + integrity sha512-bqFOQ7XRmmozp0VsKdIEe8UwZYxj0yttz7l80GBtBqdVRY48cOpXH2J/CVO7AEkV51qY0EBVXfilec18mdmQ/w== + domutils@1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" @@ -10348,21 +10765,14 @@ enhanced-resolve@^4.0.0, enhanced-resolve@^4.3.0: memory-fs "^0.5.0" tapable "^1.0.0" -enquirer@^2.3.0: +enquirer@^2.3.0, enquirer@^2.3.5: version "2.3.6" resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== dependencies: ansi-colors "^4.1.1" -enquirer@^2.3.5: - version "2.3.5" - resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.5.tgz#3ab2b838df0a9d8ab9e7dff235b0e8712ef92381" - integrity sha512-BNT1C08P9XD0vNg3J475yIUG+mVdp9T6towYFHUv897X0KoHBjB1shyrNmhmtHWKP17iSWgo7Gqh7BBuzLZMSA== - dependencies: - ansi-colors "^3.2.1" - -entities@^1.1.1, entities@^1.1.2, entities@~1.1.1: +entities@^1.1.1, entities@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== @@ -10568,9 +10978,9 @@ escodegen@^1.14.1, escodegen@^1.9.1: source-map "~0.6.1" eslint-config-prettier@^6.0.0: - version "6.10.0" - resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.10.0.tgz#7b15e303bf9c956875c948f6b21500e48ded6a7f" - integrity sha512-AtndijGte1rPILInUdHjvKEGbIV06NuvPrqlIEaEaWtbtvJh464mDeyGMdZEQMsGvC0ZVkiex1fSNcC4HAbRGg== + version "6.14.0" + resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.14.0.tgz#390e7863a8ae99970981933826476169285b3a27" + integrity sha512-DbVwh0qZhAC7CNDWcq8cBdK6FcVHiMTKmCypOPWeZkp9hJ8xYwTaWSa6bb6cjfi8KOeJy0e9a8Izxyx+O4+gCQ== dependencies: get-stdin "^6.0.0" @@ -10618,7 +11028,7 @@ eslint-plugin-graphql@^4.0.0: lodash.flatten "^4.4.0" lodash.without "^4.4.0" -eslint-plugin-import@^2.20.2, eslint-plugin-import@^2.22.0: +eslint-plugin-import@^2.20.2: version "2.22.0" resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.0.tgz#92f7736fe1fde3e2de77623c838dd992ff5ffb7e" integrity sha512-66Fpf1Ln6aIS5Gr/55ts19eUuoDhAbZgnr6UxK5hbDx6l/QgQgx61AePq+BV4PP2uXQFClgMVzep5zZ94qqsxg== @@ -10637,12 +11047,12 @@ eslint-plugin-import@^2.20.2, eslint-plugin-import@^2.22.0: resolve "^1.17.0" tsconfig-paths "^3.9.0" -eslint-plugin-jest@^23.6.0: - version "23.8.2" - resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-23.8.2.tgz#6f28b41c67ef635f803ebd9e168f6b73858eb8d4" - integrity sha512-xwbnvOsotSV27MtAe7s8uGWOori0nUsrXh2f1EnpmXua8sDfY6VZhHAhHg2sqK7HBNycRQExF074XSZ7DvfoFg== +eslint-plugin-jest@^24.1.0: + version "24.1.0" + resolved "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-24.1.0.tgz#6708037d7602e5288ce877fd0103f329dc978361" + integrity sha512-827YJ+E8B9PvXu/0eiVSNFfxxndbKv+qE/3GSMhdorCaeaOehtqHGX2YDW9B85TEOre9n/zscledkFW/KbnyGg== dependencies: - "@typescript-eslint/experimental-utils" "^2.5.0" + "@typescript-eslint/experimental-utils" "^4.0.1" eslint-plugin-jsx-a11y@^6.2.1: version "6.2.3" @@ -10712,49 +11122,48 @@ eslint-scope@^4.0.3: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-scope@^5.0.0, eslint-scope@^5.1.0: - version "5.1.0" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.0.tgz#d0f971dfe59c69e0cada684b23d49dbf82600ce5" - integrity sha512-iiGRvtxWqgtx5m8EyQUJihBloE4EnYeGE/bz1wSPwJE6tZuJUtHlhqDM4Xj2ukE8Dyy1+HCZ4hE0fzIVMzb58w== +eslint-scope@^5.0.0, eslint-scope@^5.1.1: + version "5.1.1" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== dependencies: - esrecurse "^4.1.0" + esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-utils@^1.4.3: - version "1.4.3" - resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" - integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q== +eslint-utils@^2.0.0, eslint-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" + integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== dependencies: eslint-visitor-keys "^1.1.0" -eslint-utils@^2.0.0: +eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" + integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== + +eslint-visitor-keys@^2.0.0: version "2.0.0" - resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.0.0.tgz#7be1cc70f27a72a76cd14aa698bcabed6890e1cd" - integrity sha512-0HCPuJv+7Wv1bACm8y5/ECVfYdfsAm9xmVb7saeFlxjPYALefjhbYoCkBjPdPzGH8wWyTpAez82Fh3VKYEZ8OA== - dependencies: - eslint-visitor-keys "^1.1.0" - -eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.2.0.tgz#74415ac884874495f78ec2a97349525344c981fa" - integrity sha512-WFb4ihckKil6hu3Dp798xdzSfddwKKU3+nGniKF6HfeW6OLd2OUDEPP7TcHtB5+QXOKg2s6B2DaMPE1Nn/kxKQ== + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8" + integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ== eslint@^7.1.0: - version "7.4.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-7.4.0.tgz#4e35a2697e6c1972f9d6ef2b690ad319f80f206f" - integrity sha512-gU+lxhlPHu45H3JkEGgYhWhkR9wLHHEXC9FbWFnTlEkbKyZKWgWRLgf61E8zWmBuI6g5xKBph9ltg3NtZMVF8g== + version "7.12.1" + resolved "https://registry.npmjs.org/eslint/-/eslint-7.12.1.tgz#bd9a81fa67a6cfd51656cdb88812ce49ccec5801" + integrity sha512-HlMTEdr/LicJfN08LB3nM1rRYliDXOmfoO4vj39xN6BLpFzF00hbwBoqHk8UcJ2M/3nlARZWy/mslvGEuZFvsg== dependencies: "@babel/code-frame" "^7.0.0" + "@eslint/eslintrc" "^0.2.1" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" debug "^4.0.1" doctrine "^3.0.0" enquirer "^2.3.5" - eslint-scope "^5.1.0" - eslint-utils "^2.0.0" - eslint-visitor-keys "^1.2.0" - espree "^7.1.0" + eslint-scope "^5.1.1" + eslint-utils "^2.1.0" + eslint-visitor-keys "^2.0.0" + espree "^7.3.0" esquery "^1.2.0" esutils "^2.0.2" file-entry-cache "^5.0.1" @@ -10768,7 +11177,7 @@ eslint@^7.1.0: js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" - lodash "^4.17.14" + lodash "^4.17.19" minimatch "^3.0.4" natural-compare "^1.4.0" optionator "^0.9.1" @@ -10786,14 +11195,14 @@ esm@^3.2.25: resolved "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== -espree@^7.1.0: - version "7.1.0" - resolved "https://registry.npmjs.org/espree/-/espree-7.1.0.tgz#a9c7f18a752056735bf1ba14cb1b70adc3a5ce1c" - integrity sha512-dcorZSyfmm4WTuTnE5Y7MEN1DyoPYy1ZR783QW1FJoenn7RailyWFsq/UL6ZAAA7uXurN9FIpYyUs3OfiIW+Qw== +espree@^7.3.0: + version "7.3.0" + resolved "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz#dc30437cf67947cf576121ebd780f15eeac72348" + integrity sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw== dependencies: - acorn "^7.2.0" + acorn "^7.4.0" acorn-jsx "^5.2.0" - eslint-visitor-keys "^1.2.0" + eslint-visitor-keys "^1.3.0" esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" @@ -10814,6 +11223,13 @@ esrecurse@^4.1.0: dependencies: estraverse "^4.1.0" +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.3.0" resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" @@ -10824,6 +11240,11 @@ estraverse@^5.1.0: resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642" integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== +estraverse@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" + integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== + estree-walker@^0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" @@ -10834,6 +11255,11 @@ estree-walker@^1.0.1: resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== +estree-walker@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.1.tgz#f8e030fb21cefa183b44b7ad516b747434e7a3e0" + integrity sha512-tF0hv+Yi2Ot1cwj9eYHtxC0jB9bmjacjQs6ZBTj82H8JwUywFuc+7E83NWfNMwHXZc11mjfFcVXPe9gEP4B8dg== + esutils@^2.0.2: version "2.0.3" resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -10999,16 +11425,16 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/expect/-/expect-26.4.2.tgz#36db120928a5a2d7d9736643032de32f24e1b2a1" - integrity sha512-IlJ3X52Z0lDHm7gjEp+m76uX46ldH5VpqmU0006vqDju/285twh7zaWMRhs67VpQhBwjjMchk+p5aA0VkERCAA== +expect@^26.5.3: + version "26.5.3" + resolved "https://registry.npmjs.org/expect/-/expect-26.5.3.tgz#89d9795036f7358b0a9a5243238eb8086482d741" + integrity sha512-kkpOhGRWGOr+TEFUnYAjfGvv35bfP+OlPtqPIJpOCR9DVtv8QV+p8zG0Edqafh80fsjeE+7RBcVUq1xApnYglw== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" ansi-styles "^4.0.0" jest-get-type "^26.3.0" - jest-matcher-utils "^26.4.2" - jest-message-util "^26.3.0" + jest-matcher-utils "^26.5.2" + jest-message-util "^26.5.2" jest-regex-util "^26.0.0" express-prom-bundle@^6.1.0: @@ -11154,11 +11580,6 @@ fast-deep-equal@2.0.1, fast-deep-equal@^2.0.1: resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= -fast-deep-equal@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614" - integrity sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ= - fast-deep-equal@^3.0.0, fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -11220,13 +11641,6 @@ fast-shallow-equal@^1.0.0: resolved "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b" integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw== -fast-url-parser@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" - integrity sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0= - dependencies: - punycode "^1.3.2" - fastest-stable-stringify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-1.0.1.tgz#9122d406d4c9d98bea644a6b6853d5874b87b028" @@ -11985,10 +12399,10 @@ git-url-parse@^11.1.2: dependencies: git-up "^4.0.0" -git-url-parse@^11.2.0: - version "11.2.0" - resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.2.0.tgz#2955fd51befd6d96ea1389bbe2ef57e8e6042b04" - integrity sha512-KPoHZg8v+plarZvto4ruIzzJLFQoRx+sUs5DQSr07By9IBKguVd+e6jwrFR6/TP6xrCJlNV1tPqLO1aREc7O2g== +git-url-parse@^11.4.0: + version "11.4.0" + resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.0.tgz#f2bb1f2b00f05552540e95a62e31399a639a6aa6" + integrity sha512-KlIa5jvMYLjXMQXkqpFzobsyD/V2K5DRHl5OAf+6oDFPlPLxrGDVQlIdI63c4/Kt6kai4kALENSALlzTGST3GQ== dependencies: git-up "^4.0.0" @@ -12048,7 +12462,7 @@ glob-to-regexp@^0.3.0: resolved "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= -glob@7.1.6, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob@7.1.6, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@~7.1.6: version "7.1.6" resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== @@ -12130,7 +12544,7 @@ globalthis@^1.0.0: dependencies: define-properties "^1.1.3" -globby@11.0.1, globby@^11.0.0: +globby@11.0.1, globby@^11.0.0, globby@^11.0.1: version "11.0.1" resolved "https://registry.npmjs.org/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== @@ -12234,23 +12648,6 @@ got@^10.7.0: to-readable-stream "^2.0.0" type-fest "^0.10.0" -got@^11.1.4: - version "11.6.2" - resolved "https://registry.npmjs.org/got/-/got-11.6.2.tgz#79d7bb8c11df212b97f25565407a1f4ae73210ec" - integrity sha512-/21qgUePCeus29Jk7MEti8cgQUNXFSWfIevNIk4H7u1wmXNDrGPKPY6YsPY+o9CIT/a2DjCjRz0x1nM9FtS2/A== - dependencies: - "@sindresorhus/is" "^3.1.1" - "@szmarczak/http-timer" "^4.0.5" - "@types/cacheable-request" "^6.0.1" - "@types/responselike" "^1.0.0" - cacheable-lookup "^5.0.3" - cacheable-request "^7.0.1" - decompress-response "^6.0.0" - http2-wrapper "^1.0.0-beta.5.2" - lowercase-keys "^2.0.0" - p-cancelable "^2.0.0" - responselike "^2.0.0" - got@^11.5.2: version "11.6.0" resolved "https://registry.npmjs.org/got/-/got-11.6.0.tgz#4978c78f3cbc3a45ee95381f8bb6efd1db1f4638" @@ -12285,6 +12682,23 @@ got@^11.6.2: p-cancelable "^2.0.0" responselike "^2.0.0" +got@^11.7.0: + version "11.8.0" + resolved "https://registry.npmjs.org/got/-/got-11.8.0.tgz#be0920c3586b07fd94add3b5b27cb28f49e6545f" + integrity sha512-k9noyoIIY9EejuhaBNLyZ31D5328LeqnyPNXJQb2XlJZcKakLqN5m6O/ikhq/0lw56kUYS54fVm+D1x57YC9oQ== + dependencies: + "@sindresorhus/is" "^4.0.0" + "@szmarczak/http-timer" "^4.0.5" + "@types/cacheable-request" "^6.0.1" + "@types/responselike" "^1.0.0" + cacheable-lookup "^5.0.3" + cacheable-request "^7.0.1" + decompress-response "^6.0.0" + http2-wrapper "^1.0.0-beta.5.2" + lowercase-keys "^2.0.0" + p-cancelable "^2.0.0" + responselike "^2.0.0" + got@^9.6.0: version "9.6.0" resolved "https://registry.npmjs.org/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" @@ -12328,6 +12742,13 @@ graphiql@^1.0.0-alpha.10: regenerator-runtime "^0.13.5" theme-ui "^0.3.1" +graphlib@^2.1.8: + version "2.1.8" + resolved "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz#5761d414737870084c92ec7b5dbcb0592c9d35da" + integrity sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A== + dependencies: + lodash "^4.17.15" + graphql-config@^3.0.2: version "3.0.3" resolved "https://registry.npmjs.org/graphql-config/-/graphql-config-3.0.3.tgz#58907c65ed7d6e04132321450b60e57863ea9a5f" @@ -13076,6 +13497,11 @@ immer@1.10.0: resolved "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== +immer@^7.0.9: + version "7.0.9" + resolved "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz#28e7552c21d39dd76feccd2b800b7bc86ee4a62e" + integrity sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A== + immutable@>=3.8.2, immutable@^3.8.1, immutable@^3.8.2, immutable@^3.x.x: version "3.8.2" resolved "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" @@ -13191,7 +13617,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -13418,11 +13844,16 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" -is-buffer@^1.0.2, is-buffer@^1.1.4, is-buffer@^1.1.5: +is-buffer@^1.0.2, is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== +is-buffer@^2.0.0: + version "2.0.4" + resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623" + integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A== + is-callable@^1.1.4, is-callable@^1.1.5: version "1.1.5" resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" @@ -13683,6 +14114,11 @@ is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= +is-plain-obj@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -13712,12 +14148,12 @@ is-promise@^2.1, is-promise@^2.1.0: resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== -is-reference@^1.1.2: - version "1.1.4" - resolved "https://registry.npmjs.org/is-reference/-/is-reference-1.1.4.tgz#3f95849886ddb70256a3e6d062b1a68c13c51427" - integrity sha512-uJA/CDPO3Tao3GTrxYn6AwkM4nUPJiGGYu5+cB8qbC7WGFlrKZbiRo7SFKxUAEpFUfiHofWCXBUNhvYJMh+6zw== +is-reference@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" + integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ== dependencies: - "@types/estree" "0.0.39" + "@types/estree" "*" is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.1: version "1.1.1" @@ -13820,11 +14256,6 @@ is-utf8@^0.2.0: resolved "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= -is-whitespace-character@^1.0.0: - version "1.0.4" - resolved "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7" - integrity sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w== - is-window@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz#2c896ca53db97de45d3c33133a65d8c9f563480d" @@ -13835,11 +14266,6 @@ is-windows@^1.0.0, is-windows@^1.0.1, is-windows@^1.0.2: resolved "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== -is-word-character@^1.0.0: - version "1.0.4" - resolved "https://registry.npmjs.org/is-word-character/-/is-word-character-1.0.4.tgz#ce0e73216f98599060592f62ff31354ddbeb0230" - integrity sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA== - is-wsl@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" @@ -14003,57 +14429,57 @@ jenkins@^0.28.0: dependencies: papi "^0.29.0" -jest-changed-files@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.3.0.tgz#68fb2a7eb125f50839dab1f5a17db3607fe195b1" - integrity sha512-1C4R4nijgPltX6fugKxM4oQ18zimS7LqQ+zTTY8lMCMFPrxqBFb7KJH0Z2fRQJvw2Slbaipsqq7s1mgX5Iot+g== +jest-changed-files@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.5.2.tgz#330232c6a5c09a7f040a5870e8f0a9c6abcdbed5" + integrity sha512-qSmssmiIdvM5BWVtyK/nqVpN3spR5YyvkvPqz1x3BR1bwIxsWmU/MGwLoCrPNLbkG2ASAKfvmJpOduEApBPh2w== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" execa "^4.0.0" throat "^5.0.0" -jest-cli@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-cli/-/jest-cli-26.4.2.tgz#24afc6e4dfc25cde4c7ec4226fb7db5f157c21da" - integrity sha512-zb+lGd/SfrPvoRSC/0LWdaWCnscXc1mGYW//NP4/tmBvRPT3VntZ2jtKUONsRi59zc5JqmsSajA9ewJKFYp8Cw== +jest-cli@^26.5.3: + version "26.5.3" + resolved "https://registry.npmjs.org/jest-cli/-/jest-cli-26.5.3.tgz#f936b98f247b76b7bc89c7af50af82c88e356a80" + integrity sha512-HkbSvtugpSXBf2660v9FrNVUgxvPkssN8CRGj9gPM8PLhnaa6zziFiCEKQAkQS4uRzseww45o0TR+l6KeRYV9A== dependencies: - "@jest/core" "^26.4.2" - "@jest/test-result" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/core" "^26.5.3" + "@jest/test-result" "^26.5.2" + "@jest/types" "^26.5.2" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" import-local "^3.0.2" is-ci "^2.0.0" - jest-config "^26.4.2" - jest-util "^26.3.0" - jest-validate "^26.4.2" + jest-config "^26.5.3" + jest-util "^26.5.2" + jest-validate "^26.5.3" prompts "^2.0.1" - yargs "^15.3.1" + yargs "^15.4.1" -jest-config@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-config/-/jest-config-26.4.2.tgz#da0cbb7dc2c131ffe831f0f7f2a36256e6086558" - integrity sha512-QBf7YGLuToiM8PmTnJEdRxyYy3mHWLh24LJZKVdXZ2PNdizSe1B/E8bVm+HYcjbEzGuVXDv/di+EzdO/6Gq80A== +jest-config@^26.5.3: + version "26.5.3" + resolved "https://registry.npmjs.org/jest-config/-/jest-config-26.5.3.tgz#baf51c9be078c2c755c8f8a51ec0f06c762c1d3f" + integrity sha512-NVhZiIuN0GQM6b6as4CI5FSCyXKxdrx5ACMCcv/7Pf+TeCajJhJc+6dwgdAVPyerUFB9pRBIz3bE7clSrRge/w== dependencies: "@babel/core" "^7.1.0" - "@jest/test-sequencer" "^26.4.2" - "@jest/types" "^26.3.0" - babel-jest "^26.3.0" + "@jest/test-sequencer" "^26.5.3" + "@jest/types" "^26.5.2" + babel-jest "^26.5.2" chalk "^4.0.0" deepmerge "^4.2.2" glob "^7.1.1" graceful-fs "^4.2.4" - jest-environment-jsdom "^26.3.0" - jest-environment-node "^26.3.0" + jest-environment-jsdom "^26.5.2" + jest-environment-node "^26.5.2" jest-get-type "^26.3.0" - jest-jasmine2 "^26.4.2" + jest-jasmine2 "^26.5.3" jest-regex-util "^26.0.0" - jest-resolve "^26.4.0" - jest-util "^26.3.0" - jest-validate "^26.4.2" + jest-resolve "^26.5.2" + jest-util "^26.5.2" + jest-validate "^26.5.3" micromatch "^4.0.2" - pretty-format "^26.4.2" + pretty-format "^26.5.2" jest-css-modules@^2.1.0: version "2.1.0" @@ -14062,7 +14488,7 @@ jest-css-modules@^2.1.0: dependencies: identity-obj-proxy "3.0.0" -jest-diff@^25.1.0, jest-diff@^25.2.1: +jest-diff@^25.2.1: version "25.5.0" resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz#1dd26ed64f96667c068cef026b677dfa01afcfa9" integrity sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A== @@ -14072,15 +14498,15 @@ jest-diff@^25.1.0, jest-diff@^25.2.1: jest-get-type "^25.2.6" pretty-format "^25.5.0" -jest-diff@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.4.2.tgz#a1b7b303bcc534aabdb3bd4a7caf594ac059f5aa" - integrity sha512-6T1XQY8U28WH0Z5rGpQ+VqZSZz8EN8rZcBtfvXaOkbwxIEeRre6qnuZQlbY1AJ4MKDxQF8EkrCvK+hL/VkyYLQ== +jest-diff@^26.0.0, jest-diff@^26.5.2: + version "26.6.1" + resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c" + integrity sha512-BBNy/zin2m4kG5In126O8chOBxLLS/XMTuuM2+YhgyHk87ewPzKTuTJcqj3lOWOi03NNgrl+DkMeV/exdvG9gg== dependencies: chalk "^4.0.0" - diff-sequences "^26.3.0" + diff-sequences "^26.5.0" jest-get-type "^26.3.0" - pretty-format "^26.4.2" + pretty-format "^26.6.1" jest-docblock@^26.0.0: version "26.0.0" @@ -14089,41 +14515,41 @@ jest-docblock@^26.0.0: dependencies: detect-newline "^3.0.0" -jest-each@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-each/-/jest-each-26.4.2.tgz#bb14f7f4304f2bb2e2b81f783f989449b8b6ffae" - integrity sha512-p15rt8r8cUcRY0Mvo1fpkOGYm7iI8S6ySxgIdfh3oOIv+gHwrHTy5VWCGOecWUhDsit4Nz8avJWdT07WLpbwDA== +jest-each@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-each/-/jest-each-26.5.2.tgz#35e68d6906a7f826d3ca5803cfe91d17a5a34c31" + integrity sha512-w7D9FNe0m2D3yZ0Drj9CLkyF/mGhmBSULMQTypzAKR746xXnjUrK8GUJdlLTWUF6dd0ks3MtvGP7/xNFr9Aphg== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" chalk "^4.0.0" jest-get-type "^26.3.0" - jest-util "^26.3.0" - pretty-format "^26.4.2" + jest-util "^26.5.2" + pretty-format "^26.5.2" -jest-environment-jsdom@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.3.0.tgz#3b749ba0f3a78e92ba2c9ce519e16e5dd515220c" - integrity sha512-zra8He2btIMJkAzvLaiZ9QwEPGEetbxqmjEBQwhH3CA+Hhhu0jSiEJxnJMbX28TGUvPLxBt/zyaTLrOPF4yMJA== +jest-environment-jsdom@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.5.2.tgz#5feab05b828fd3e4b96bee5e0493464ddd2bb4bc" + integrity sha512-fWZPx0bluJaTQ36+PmRpvUtUlUFlGGBNyGX1SN3dLUHHMcQ4WseNEzcGGKOw4U5towXgxI4qDoI3vwR18H0RTw== dependencies: - "@jest/environment" "^26.3.0" - "@jest/fake-timers" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/environment" "^26.5.2" + "@jest/fake-timers" "^26.5.2" + "@jest/types" "^26.5.2" "@types/node" "*" - jest-mock "^26.3.0" - jest-util "^26.3.0" - jsdom "^16.2.2" + jest-mock "^26.5.2" + jest-util "^26.5.2" + jsdom "^16.4.0" -jest-environment-node@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.3.0.tgz#56c6cfb506d1597f94ee8d717072bda7228df849" - integrity sha512-c9BvYoo+FGcMj5FunbBgtBnbR5qk3uky8PKyRVpSfe2/8+LrNQMiXX53z6q2kY+j15SkjQCOSL/6LHnCPLVHNw== +jest-environment-node@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.5.2.tgz#275a0f01b5e47447056f1541a15ed4da14acca03" + integrity sha512-YHjnDsf/GKFCYMGF1V+6HF7jhY1fcLfLNBDjhAOvFGvt6d8vXvNdJGVM7uTZ2VO/TuIyEFhPGaXMX5j3h7fsrA== dependencies: - "@jest/environment" "^26.3.0" - "@jest/fake-timers" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/environment" "^26.5.2" + "@jest/fake-timers" "^26.5.2" + "@jest/types" "^26.5.2" "@types/node" "*" - jest-mock "^26.3.0" - jest-util "^26.3.0" + jest-mock "^26.5.2" + jest-util "^26.5.2" jest-esm-transformer@^1.0.0: version "1.0.0" @@ -14133,15 +14559,7 @@ jest-esm-transformer@^1.0.0: "@babel/core" "^7.4.4" "@babel/plugin-transform-modules-commonjs" "^7.4.4" -jest-fetch-mock@^3.0.3: - version "3.0.3" - resolved "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" - integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== - dependencies: - cross-fetch "^3.0.4" - promise-polyfill "^8.1.3" - -jest-get-type@^25.1.0, jest-get-type@^25.2.6: +jest-get-type@^25.2.6: version "25.2.6" resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig== @@ -14151,99 +14569,89 @@ jest-get-type@^26.3.0: resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== -jest-haste-map@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.3.0.tgz#c51a3b40100d53ab777bfdad382d2e7a00e5c726" - integrity sha512-DHWBpTJgJhLLGwE5Z1ZaqLTYqeODQIZpby0zMBsCU9iRFHYyhklYqP4EiG73j5dkbaAdSZhgB938mL51Q5LeZA== +jest-haste-map@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.5.2.tgz#a15008abfc502c18aa56e4919ed8c96304ceb23d" + integrity sha512-lJIAVJN3gtO3k4xy+7i2Xjtwh8CfPcH08WYjZpe9xzveDaqGw9fVNCpkYu6M525wKFVkLmyi7ku+DxCAP1lyMA== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" "@types/graceful-fs" "^4.1.2" "@types/node" "*" anymatch "^3.0.3" fb-watchman "^2.0.0" graceful-fs "^4.2.4" jest-regex-util "^26.0.0" - jest-serializer "^26.3.0" - jest-util "^26.3.0" - jest-worker "^26.3.0" + jest-serializer "^26.5.0" + jest-util "^26.5.2" + jest-worker "^26.5.0" micromatch "^4.0.2" sane "^4.0.3" walker "^1.0.7" optionalDependencies: fsevents "^2.1.2" -jest-jasmine2@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.4.2.tgz#18a9d5bec30904267ac5e9797570932aec1e2257" - integrity sha512-z7H4EpCldHN1J8fNgsja58QftxBSL+JcwZmaXIvV9WKIM+x49F4GLHu/+BQh2kzRKHAgaN/E82od+8rTOBPyPA== +jest-jasmine2@^26.5.3: + version "26.5.3" + resolved "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.5.3.tgz#baad2114ce32d16aff25aeb877d18bb4e332dc4c" + integrity sha512-nFlZOpnGlNc7y/+UkkeHnvbOM+rLz4wB1AimgI9QhtnqSZte0wYjbAm8hf7TCwXlXgDwZxAXo6z0a2Wzn9FoOg== dependencies: "@babel/traverse" "^7.1.0" - "@jest/environment" "^26.3.0" - "@jest/source-map" "^26.3.0" - "@jest/test-result" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/environment" "^26.5.2" + "@jest/source-map" "^26.5.0" + "@jest/test-result" "^26.5.2" + "@jest/types" "^26.5.2" "@types/node" "*" chalk "^4.0.0" co "^4.6.0" - expect "^26.4.2" + expect "^26.5.3" is-generator-fn "^2.0.0" - jest-each "^26.4.2" - jest-matcher-utils "^26.4.2" - jest-message-util "^26.3.0" - jest-runtime "^26.4.2" - jest-snapshot "^26.4.2" - jest-util "^26.3.0" - pretty-format "^26.4.2" + jest-each "^26.5.2" + jest-matcher-utils "^26.5.2" + jest-message-util "^26.5.2" + jest-runtime "^26.5.3" + jest-snapshot "^26.5.3" + jest-util "^26.5.2" + pretty-format "^26.5.2" throat "^5.0.0" -jest-leak-detector@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.4.2.tgz#c73e2fa8757bf905f6f66fb9e0070b70fa0f573f" - integrity sha512-akzGcxwxtE+9ZJZRW+M2o+nTNnmQZxrHJxX/HjgDaU5+PLmY1qnQPnMjgADPGCRPhB+Yawe1iij0REe+k/aHoA== +jest-leak-detector@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.5.2.tgz#83fcf9a4a6ef157549552cb4f32ca1d6221eea69" + integrity sha512-h7ia3dLzBFItmYERaLPEtEKxy3YlcbcRSjj0XRNJgBEyODuu+3DM2o62kvIFvs3PsaYoIIv+e+nLRI61Dj1CNw== dependencies: jest-get-type "^26.3.0" - pretty-format "^26.4.2" + pretty-format "^26.5.2" -jest-matcher-utils@^25.1.0: - version "25.1.0" - resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.1.0.tgz#fa5996c45c7193a3c24e73066fc14acdee020220" - integrity sha512-KGOAFcSFbclXIFE7bS4C53iYobKI20ZWleAdAFun4W1Wz1Kkej8Ng6RRbhL8leaEvIOjGXhGf/a1JjO8bkxIWQ== - dependencies: - chalk "^3.0.0" - jest-diff "^25.1.0" - jest-get-type "^25.1.0" - pretty-format "^25.1.0" - -jest-matcher-utils@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.4.2.tgz#fa81f3693f7cb67e5fc1537317525ef3b85f4b06" - integrity sha512-KcbNqWfWUG24R7tu9WcAOKKdiXiXCbMvQYT6iodZ9k1f7065k0keUOW6XpJMMvah+hTfqkhJhRXmA3r3zMAg0Q== +jest-matcher-utils@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.5.2.tgz#6aa2c76ce8b9c33e66f8856ff3a52bab59e6c85a" + integrity sha512-W9GO9KBIC4gIArsNqDUKsLnhivaqf8MSs6ujO/JDcPIQrmY+aasewweXVET8KdrJ6ADQaUne5UzysvF/RR7JYA== dependencies: chalk "^4.0.0" - jest-diff "^26.4.2" + jest-diff "^26.5.2" jest-get-type "^26.3.0" - pretty-format "^26.4.2" + pretty-format "^26.5.2" -jest-message-util@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.3.0.tgz#3bdb538af27bb417f2d4d16557606fd082d5841a" - integrity sha512-xIavRYqr4/otGOiLxLZGj3ieMmjcNE73Ui+LdSW/Y790j5acqCsAdDiLIbzHCZMpN07JOENRWX5DcU+OQ+TjTA== +jest-message-util@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.5.2.tgz#6c4c4c46dcfbabb47cd1ba2f6351559729bc11bb" + integrity sha512-Ocp9UYZ5Jl15C5PNsoDiGEk14A4NG0zZKknpWdZGoMzJuGAkVt10e97tnEVMYpk7LnQHZOfuK2j/izLBMcuCZw== dependencies: "@babel/code-frame" "^7.0.0" - "@jest/types" "^26.3.0" - "@types/stack-utils" "^1.0.1" + "@jest/types" "^26.5.2" + "@types/stack-utils" "^2.0.0" chalk "^4.0.0" graceful-fs "^4.2.4" micromatch "^4.0.2" slash "^3.0.0" stack-utils "^2.0.2" -jest-mock@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/jest-mock/-/jest-mock-26.3.0.tgz#ee62207c3c5ebe5f35b760e1267fee19a1cfdeba" - integrity sha512-PeaRrg8Dc6mnS35gOo/CbZovoDPKAeB1FICZiuagAgGvbWdNNyjQjkOaGUa/3N3JtpQ/Mh9P4A2D4Fv51NnP8Q== +jest-mock@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-mock/-/jest-mock-26.5.2.tgz#c9302e8ef807f2bfc749ee52e65ad11166a1b6a1" + integrity sha512-9SiU4b5PtO51v0MtJwVRqeGEroH66Bnwtq4ARdNP7jNXbpT7+ByeWNAk4NeT/uHfNSVDXEXgQo1XRuwEqS6Rdw== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" "@types/node" "*" jest-pnp-resolver@^1.2.2: @@ -14256,154 +14664,155 @@ jest-regex-util@^26.0.0: resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28" integrity sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A== -jest-resolve-dependencies@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.4.2.tgz#739bdb027c14befb2fe5aabbd03f7bab355f1dc5" - integrity sha512-ADHaOwqEcVc71uTfySzSowA/RdxUpCxhxa2FNLiin9vWLB1uLPad3we+JSSROq5+SrL9iYPdZZF8bdKM7XABTQ== +jest-resolve-dependencies@^26.5.3: + version "26.5.3" + resolved "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.5.3.tgz#11483f91e534bdcd257ab21e8622799e59701aba" + integrity sha512-+KMDeke/BFK+mIQ2IYSyBz010h7zQaVt4Xie6cLqUGChorx66vVeQVv4ErNoMwInnyYHi1Ud73tDS01UbXbfLQ== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" jest-regex-util "^26.0.0" - jest-snapshot "^26.4.2" + jest-snapshot "^26.5.3" -jest-resolve@^26.4.0: - version "26.4.0" - resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.4.0.tgz#6dc0af7fb93e65b73fec0368ca2b76f3eb59a6d7" - integrity sha512-bn/JoZTEXRSlEx3+SfgZcJAVuTMOksYq9xe9O6s4Ekg84aKBObEaVXKOEilULRqviSLAYJldnoWV9c07kwtiCg== +jest-resolve@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.5.2.tgz#0d719144f61944a428657b755a0e5c6af4fc8602" + integrity sha512-XsPxojXGRA0CoDD7Vis59ucz2p3cQFU5C+19tz3tLEAlhYKkK77IL0cjYjikY9wXnOaBeEdm1rOgSJjbZWpcZg== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" chalk "^4.0.0" graceful-fs "^4.2.4" jest-pnp-resolver "^1.2.2" - jest-util "^26.3.0" + jest-util "^26.5.2" read-pkg-up "^7.0.1" resolve "^1.17.0" slash "^3.0.0" -jest-runner@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-runner/-/jest-runner-26.4.2.tgz#c3ec5482c8edd31973bd3935df5a449a45b5b853" - integrity sha512-FgjDHeVknDjw1gRAYaoUoShe1K3XUuFMkIaXbdhEys+1O4bEJS8Avmn4lBwoMfL8O5oFTdWYKcf3tEJyyYyk8g== +jest-runner@^26.5.3: + version "26.5.3" + resolved "https://registry.npmjs.org/jest-runner/-/jest-runner-26.5.3.tgz#800787459ea59c68e7505952933e33981dc3db38" + integrity sha512-qproP0Pq7IIule+263W57k2+8kWCszVJTC9TJWGUz0xJBr+gNiniGXlG8rotd0XxwonD5UiJloYoSO5vbUr5FQ== dependencies: - "@jest/console" "^26.3.0" - "@jest/environment" "^26.3.0" - "@jest/test-result" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/console" "^26.5.2" + "@jest/environment" "^26.5.2" + "@jest/test-result" "^26.5.2" + "@jest/types" "^26.5.2" "@types/node" "*" chalk "^4.0.0" emittery "^0.7.1" exit "^0.1.2" graceful-fs "^4.2.4" - jest-config "^26.4.2" + jest-config "^26.5.3" jest-docblock "^26.0.0" - jest-haste-map "^26.3.0" - jest-leak-detector "^26.4.2" - jest-message-util "^26.3.0" - jest-resolve "^26.4.0" - jest-runtime "^26.4.2" - jest-util "^26.3.0" - jest-worker "^26.3.0" + jest-haste-map "^26.5.2" + jest-leak-detector "^26.5.2" + jest-message-util "^26.5.2" + jest-resolve "^26.5.2" + jest-runtime "^26.5.3" + jest-util "^26.5.2" + jest-worker "^26.5.0" source-map-support "^0.5.6" throat "^5.0.0" -jest-runtime@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.4.2.tgz#94ce17890353c92e4206580c73a8f0c024c33c42" - integrity sha512-4Pe7Uk5a80FnbHwSOk7ojNCJvz3Ks2CNQWT5Z7MJo4tX0jb3V/LThKvD9tKPNVNyeMH98J/nzGlcwc00R2dSHQ== +jest-runtime@^26.5.3: + version "26.5.3" + resolved "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.5.3.tgz#5882ae91fd88304310f069549e6bf82f3f198bea" + integrity sha512-IDjalmn2s/Tc4GvUwhPHZ0iaXCdMRq5p6taW9P8RpU+FpG01O3+H8z+p3rDCQ9mbyyyviDgxy/LHPLzrIOKBkQ== dependencies: - "@jest/console" "^26.3.0" - "@jest/environment" "^26.3.0" - "@jest/fake-timers" "^26.3.0" - "@jest/globals" "^26.4.2" - "@jest/source-map" "^26.3.0" - "@jest/test-result" "^26.3.0" - "@jest/transform" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/console" "^26.5.2" + "@jest/environment" "^26.5.2" + "@jest/fake-timers" "^26.5.2" + "@jest/globals" "^26.5.3" + "@jest/source-map" "^26.5.0" + "@jest/test-result" "^26.5.2" + "@jest/transform" "^26.5.2" + "@jest/types" "^26.5.2" "@types/yargs" "^15.0.0" chalk "^4.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.3" graceful-fs "^4.2.4" - jest-config "^26.4.2" - jest-haste-map "^26.3.0" - jest-message-util "^26.3.0" - jest-mock "^26.3.0" + jest-config "^26.5.3" + jest-haste-map "^26.5.2" + jest-message-util "^26.5.2" + jest-mock "^26.5.2" jest-regex-util "^26.0.0" - jest-resolve "^26.4.0" - jest-snapshot "^26.4.2" - jest-util "^26.3.0" - jest-validate "^26.4.2" + jest-resolve "^26.5.2" + jest-snapshot "^26.5.3" + jest-util "^26.5.2" + jest-validate "^26.5.3" slash "^3.0.0" strip-bom "^4.0.0" - yargs "^15.3.1" + yargs "^15.4.1" -jest-serializer@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.3.0.tgz#1c9d5e1b74d6e5f7e7f9627080fa205d976c33ef" - integrity sha512-IDRBQBLPlKa4flg77fqg0n/pH87tcRKwe8zxOVTWISxGpPHYkRZ1dXKyh04JOja7gppc60+soKVZ791mruVdow== +jest-serializer@^26.5.0: + version "26.5.0" + resolved "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.5.0.tgz#f5425cc4c5f6b4b355f854b5f0f23ec6b962bc13" + integrity sha512-+h3Gf5CDRlSLdgTv7y0vPIAoLgX/SI7T4v6hy+TEXMgYbv+ztzbg5PSN6mUXAT/hXYHvZRWm+MaObVfqkhCGxA== dependencies: "@types/node" "*" graceful-fs "^4.2.4" -jest-snapshot@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.4.2.tgz#87d3ac2f2bd87ea8003602fbebd8fcb9e94104f6" - integrity sha512-N6Uub8FccKlf5SBFnL2Ri/xofbaA68Cc3MGjP/NuwgnsvWh+9hLIR/DhrxbSiKXMY9vUW5dI6EW1eHaDHqe9sg== +jest-snapshot@^26.5.3: + version "26.5.3" + resolved "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.5.3.tgz#f6b4b4b845f85d4b0dadd7cf119c55d0c1688601" + integrity sha512-ZgAk0Wm0JJ75WS4lGaeRfa0zIgpL0KD595+XmtwlIEMe8j4FaYHyZhP1LNOO+8fXq7HJ3hll54+sFV9X4+CGVw== dependencies: "@babel/types" "^7.0.0" - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" + "@types/babel__traverse" "^7.0.4" "@types/prettier" "^2.0.0" chalk "^4.0.0" - expect "^26.4.2" + expect "^26.5.3" graceful-fs "^4.2.4" - jest-diff "^26.4.2" + jest-diff "^26.5.2" jest-get-type "^26.3.0" - jest-haste-map "^26.3.0" - jest-matcher-utils "^26.4.2" - jest-message-util "^26.3.0" - jest-resolve "^26.4.0" + jest-haste-map "^26.5.2" + jest-matcher-utils "^26.5.2" + jest-message-util "^26.5.2" + jest-resolve "^26.5.2" natural-compare "^1.4.0" - pretty-format "^26.4.2" + pretty-format "^26.5.2" semver "^7.3.2" -jest-util@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.3.0.tgz#a8974b191df30e2bf523ebbfdbaeb8efca535b3e" - integrity sha512-4zpn6bwV0+AMFN0IYhH/wnzIQzRaYVrz1A8sYnRnj4UXDXbOVtWmlaZkO9mipFqZ13okIfN87aDoJWB7VH6hcw== +jest-util@^26.1.0, jest-util@^26.5.2: + version "26.6.1" + resolved "https://registry.npmjs.org/jest-util/-/jest-util-26.6.1.tgz#4cc0d09ec57f28d12d053887eec5dc976a352e9b" + integrity sha512-xCLZUqVoqhquyPLuDXmH7ogceGctbW8SMyQVjD9o+1+NPWI7t0vO08udcFLVPLgKWcvc+zotaUv/RuaR6l8HIA== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.6.1" "@types/node" "*" chalk "^4.0.0" graceful-fs "^4.2.4" is-ci "^2.0.0" micromatch "^4.0.2" -jest-validate@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-26.4.2.tgz#e871b0dfe97747133014dcf6445ee8018398f39c" - integrity sha512-blft+xDX7XXghfhY0mrsBCYhX365n8K5wNDC4XAcNKqqjEzsRUSXP44m6PL0QJEW2crxQFLLztVnJ4j7oPlQrQ== +jest-validate@^26.5.3: + version "26.5.3" + resolved "https://registry.npmjs.org/jest-validate/-/jest-validate-26.5.3.tgz#eefd5a5c87059550548c5ad8d6589746c66929e3" + integrity sha512-LX07qKeAtY+lsU0o3IvfDdN5KH9OulEGOMN1sFo6PnEf5/qjS1LZIwNk9blcBeW94pQUI9dLN9FlDYDWI5tyaA== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.5.2" camelcase "^6.0.0" chalk "^4.0.0" jest-get-type "^26.3.0" leven "^3.1.0" - pretty-format "^26.4.2" + pretty-format "^26.5.2" -jest-watcher@^26.3.0: - version "26.3.0" - resolved "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.3.0.tgz#f8ef3068ddb8af160ef868400318dc4a898eed08" - integrity sha512-XnLdKmyCGJ3VoF6G/p5ohbJ04q/vv5aH9ENI+i6BL0uu9WWB6Z7Z2lhQQk0d2AVZcRGp1yW+/TsoToMhBFPRdQ== +jest-watcher@^26.5.2: + version "26.5.2" + resolved "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.5.2.tgz#2957f4461007e0769d74b537379ecf6b7c696916" + integrity sha512-i3m1NtWzF+FXfJ3ljLBB/WQEp4uaNhX7QcQUWMokcifFTUQBDFyUMEwk0JkJ1kopHbx7Een3KX0Q7+9koGM/Pw== dependencies: - "@jest/test-result" "^26.3.0" - "@jest/types" "^26.3.0" + "@jest/test-result" "^26.5.2" + "@jest/types" "^26.5.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" - jest-util "^26.3.0" + jest-util "^26.5.2" string-length "^4.0.1" -jest-worker@^26.2.1, jest-worker@^26.3.0: +jest-worker@^26.2.1: version "26.3.0" resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-26.3.0.tgz#7c8a97e4f4364b4f05ed8bca8ca0c24de091871f" integrity sha512-Vmpn2F6IASefL+DVBhPzI2J9/GJUsqzomdeN+P+dK8/jKxbh8R3BtFnx3FIta7wYlPU62cpJMJQo4kuOowcMnw== @@ -14412,14 +14821,23 @@ jest-worker@^26.2.1, jest-worker@^26.3.0: merge-stream "^2.0.0" supports-color "^7.0.0" -jest@^26.0.1: - version "26.4.2" - resolved "https://registry.npmjs.org/jest/-/jest-26.4.2.tgz#7e8bfb348ec33f5459adeaffc1a25d5752d9d312" - integrity sha512-LLCjPrUh98Ik8CzW8LLVnSCfLaiY+wbK53U7VxnFSX7Q+kWC4noVeDvGWIFw0Amfq1lq2VfGm7YHWSLBV62MJw== +jest-worker@^26.5.0: + version "26.5.0" + resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-26.5.0.tgz#87deee86dbbc5f98d9919e0dadf2c40e3152fa30" + integrity sha512-kTw66Dn4ZX7WpjZ7T/SUDgRhapFRKWmisVAF0Rv4Fu8SLFD7eLbqpLvbxVqYhSgaWa7I+bW7pHnbyfNsH6stug== dependencies: - "@jest/core" "^26.4.2" + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^7.0.0" + +jest@^26.0.1: + version "26.5.3" + resolved "https://registry.npmjs.org/jest/-/jest-26.5.3.tgz#5e7a322d16f558dc565ca97639e85993ef5affe6" + integrity sha512-uJi3FuVSLmkZrWvaDyaVTZGLL8WcfynbRnFXyAHuEtYiSZ+ijDDIMOw1ytmftK+y/+OdAtsG9QrtbF7WIBmOyA== + dependencies: + "@jest/core" "^26.5.3" import-local "^3.0.2" - jest-cli "^26.4.2" + jest-cli "^26.5.3" jose@^1.27.1: version "1.27.1" @@ -14505,10 +14923,10 @@ jsdom@11.12.0: ws "^5.2.0" xml-name-validator "^3.0.0" -jsdom@^16.2.2: - version "16.2.2" - resolved "https://registry.npmjs.org/jsdom/-/jsdom-16.2.2.tgz#76f2f7541646beb46a938f5dc476b88705bedf2b" - integrity sha512-pDFQbcYtKBHxRaP55zGXCJWgFHkDAYbKcsXEK/3Icu9nKYZkutUXfLBwbD+09XDutkYSHcgfQLZ0qvpAAm9mvg== +jsdom@^16.4.0: + version "16.4.0" + resolved "https://registry.npmjs.org/jsdom/-/jsdom-16.4.0.tgz#36005bde2d136f73eee1a830c6d45e55408edddb" + integrity sha512-lYMm3wYdgPhrl7pDcRmvzPhhrGVBeVhPIqeHjzeiHN3DFmD1RBpbExbi8vU7BJdH8VAZYovR8DMt0PNNDM7k8w== dependencies: abab "^2.0.3" acorn "^7.1.1" @@ -14530,7 +14948,7 @@ jsdom@^16.2.2: tough-cookie "^3.0.1" w3c-hr-time "^1.0.2" w3c-xmlserializer "^2.0.0" - webidl-conversions "^6.0.0" + webidl-conversions "^6.1.0" whatwg-encoding "^1.0.5" whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" @@ -14585,24 +15003,14 @@ json-schema-merge-allof@^0.6.0: json-schema-compare "^0.2.2" lodash "^4.17.4" -json-schema-migrate@^0.2.0: - version "0.2.0" - resolved "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-0.2.0.tgz#ba47a5b0072fc72396460e1bd60b44d52178bbc6" - integrity sha1-ukelsAcvxyOWRg4b1gtE1SF4u8Y= +json-schema-merge-allof@^0.7.0: + version "0.7.0" + resolved "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.7.0.tgz#84d3e8c3e03d3060014286958eb8834fa9d76304" + integrity sha512-kvsuSVnl1n5xnNEu5ed4o8r8ujSA4/IgRtHmpgfMfa7FOMIRAzN4F9qbuklouTn5J8bi83y6MQ11n+ERMMTXZg== dependencies: - ajv "^5.0.0" - -json-schema-ref-parser@^9.0.6: - version "9.0.6" - resolved "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz#fc89a5e6b853f2abe8c0af30d3874196526adb60" - integrity sha512-z0JGv7rRD3CnJbZY/qCpscyArdtLJhr/wRBmFUdoZ8xMjsFyNdILSprG2degqRLjBjyhZHAEBpGOxniO9rKTxA== - dependencies: - "@apidevtools/json-schema-ref-parser" "9.0.6" - -json-schema-traverse@^0.3.0: - version "0.3.1" - resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" - integrity sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A= + compute-lcm "^1.1.0" + json-schema-compare "^0.2.2" + lodash "^4.17.4" json-schema-traverse@^0.4.1: version "0.4.1" @@ -14924,23 +15332,21 @@ kleur@^3.0.3: resolved "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -knex@^0.21.1: - version "0.21.5" - resolved "https://registry.npmjs.org/knex/-/knex-0.21.5.tgz#c4be1958488f348aed3510aa4b7115639ee1bd01" - integrity sha512-cQj7F2D/fu03eTr6ZzYCYKdB9w7fPYlvTiU/f2OeXay52Pq5PwD+NAkcf40WDnppt/4/4KukROwlMOaE7WArcA== +knex@^0.21.6: + version "0.21.8" + resolved "https://registry.npmjs.org/knex/-/knex-0.21.8.tgz#e5c07af61ee6aa006d3468e10e3a69351deb0c26" + integrity sha512-ziUu4vAlIGA8j2l0S4xcD1d3XdpJA4HYGhwHEhgAgefGCmB1OLSjUGCs/ebkJal42fSvAkyZaB0tcOtTXKgS5g== dependencies: colorette "1.2.1" commander "^5.1.0" debug "4.1.1" esm "^3.2.25" getopts "2.2.5" - inherits "~2.0.4" interpret "^2.2.0" liftoff "3.1.0" lodash "^4.17.20" - mkdirp "^1.0.4" pg-connection-string "2.3.0" - tarn "^3.0.0" + tarn "^3.0.1" tildify "2.0.0" uuid "^7.0.3" v8flags "^3.2.0" @@ -15101,6 +15507,13 @@ linkify-it@^2.0.0: dependencies: uc.micro "^1.0.1" +linkify-it@^3.0.1: + version "3.0.2" + resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.2.tgz#f55eeb8bc1d3ae754049e124ab3bb56d97797fb8" + integrity sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ== + dependencies: + uc.micro "^1.0.1" + lint-staged@^10.1.0: version "10.2.11" resolved "https://registry.npmjs.org/lint-staged/-/lint-staged-10.2.11.tgz#713c80877f2dc8b609b05bc59020234e766c9720" @@ -15515,6 +15928,11 @@ long@^4.0.0: resolved "https://registry.npmjs.org/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== +longest-streak@^2.0.0: + version "2.0.4" + resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" + integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -15597,7 +16015,7 @@ macos-release@^2.2.0: resolved "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" integrity sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA== -magic-string@^0.25.2: +magic-string@^0.25.7: version "0.25.7" resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== @@ -15699,11 +16117,6 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" -markdown-escapes@^1.0.0: - version "1.0.4" - resolved "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" - integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg== - markdown-it@^10.0.0: version "10.0.0" resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc" @@ -15715,17 +16128,24 @@ markdown-it@^10.0.0: mdurl "^1.0.1" uc.micro "^1.0.5" -markdown-it@^9.1.0: - version "9.1.0" - resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-9.1.0.tgz#df9601c168568704d554b1fff9af0c5b561168d9" - integrity sha512-xHKG4C8iPriyfu/jc2hsCC045fKrMQ0VexX2F1FGYiRxDxqMB2aAhF8WauJ3fltn2kb90moGBkiiEdooGIg55w== +markdown-it@^11.0.1: + version "11.0.1" + resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.1.tgz#b54f15ec2a2193efa66dda1eb4173baea08993d6" + integrity sha512-aU1TzmBKcWNNYvH9pjq6u92BML+Hz3h5S/QpfTFwiQF852pLT+9qHsrhM9JYipkOXZxGn+sGH8oyJE9FD9WezQ== dependencies: argparse "^1.0.7" - entities "~1.1.1" - linkify-it "^2.0.0" + entities "~2.0.0" + linkify-it "^3.0.1" mdurl "^1.0.1" uc.micro "^1.0.5" +markdown-table@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz#194a90ced26d31fe753d8b9434430214c011865b" + integrity sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A== + dependencies: + repeat-string "^1.0.0" + markdown-to-jsx@^6.11.4: version "6.11.4" resolved "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-6.11.4.tgz#b4528b1ab668aef7fe61c1535c27e837819392c5" @@ -15778,6 +16198,70 @@ mdast-add-list-metadata@1.0.1: dependencies: unist-util-visit-parents "1.1.2" +mdast-util-from-markdown@^0.8.0: + version "0.8.1" + resolved "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.1.tgz#781371d493cac11212947226190270c15dc97116" + integrity sha512-qJXNcFcuCSPqUF0Tb0uYcFDIq67qwB3sxo9RPdf9vG8T90ViKnksFqdB/Coq2a7sTnxL/Ify2y7aIQXDkQFH0w== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-string "^1.0.0" + micromark "~2.10.0" + parse-entities "^2.0.0" + +mdast-util-gfm-autolink-literal@^0.1.0: + version "0.1.1" + resolved "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-0.1.1.tgz#94675074d725ed7254b3172fa7e7c3252960de39" + integrity sha512-gJ2xSpqKCetSr22GEWpZH3f5ffb4pPn/72m4piY0v7T/S+O7n7rw+sfoPLhb2b4O7WdnERoYdALRcmD68FMtlw== + +mdast-util-gfm-strikethrough@^0.2.0: + version "0.2.2" + resolved "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-0.2.2.tgz#6e9ddd33ce41b06a60463e817f6ef4cf7bfa0655" + integrity sha512-T37ZbaokJcRbHROXmoVAieWnesPD5N21tv2ifYzaGRLbkh1gknItUGhZzHefUn5Zc/eaO/iTDSAFOBrn/E8kWw== + dependencies: + mdast-util-to-markdown "^0.5.0" + +mdast-util-gfm-table@^0.1.0: + version "0.1.4" + resolved "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-0.1.4.tgz#5b3d71d16294c6fae1c2c424d3a081ffc7407b83" + integrity sha512-T4xFSON9kUb/IpYA5N+KGWcsdGczAvILvKiXQwUGind6V9fvjPCR9yhZnIeaLdBWXaz3m/Gq77ZtuLMjtFR4IQ== + dependencies: + markdown-table "^2.0.0" + mdast-util-to-markdown "^0.5.0" + +mdast-util-gfm-task-list-item@^0.1.0: + version "0.1.4" + resolved "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-0.1.4.tgz#5899b1320d1b826f7658ac3171babf4ae1e626a2" + integrity sha512-AMiHyBHvaYN2p3ztFv7gDgTF7keZDaA9plTixRXWT0aqL0QdN43QaG5+hzcRNbjCsEWBxWhpcNk1Diq0TiIEvw== + dependencies: + mdast-util-to-markdown "^0.5.0" + +mdast-util-gfm@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-0.1.0.tgz#bac0efe703670d1b40474e6be13dbdd887273a04" + integrity sha512-HLfygQL6HdhJhFbLta4Ki9hClrzyAxRjyRvpm5caN65QZL+NyHPmqFlnF9vm1Rn58JT2+AbLwNcEDY4MEvkk8Q== + dependencies: + mdast-util-gfm-autolink-literal "^0.1.0" + mdast-util-gfm-strikethrough "^0.2.0" + mdast-util-gfm-table "^0.1.0" + mdast-util-gfm-task-list-item "^0.1.0" + +mdast-util-to-markdown@^0.5.0: + version "0.5.3" + resolved "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-0.5.3.tgz#e05c54a3ccd239bab63c48a1e5b5747f0dcd5aca" + integrity sha512-sr8q7fQJ1xoCqZSXW6dO/MYu2Md+a4Hfk9uO+XHCfiBhVM0EgWtfAV7BuN+ff6otUeu2xDyt1o7vhZGwOG3+BA== + dependencies: + "@types/unist" "^2.0.0" + longest-streak "^2.0.0" + mdast-util-to-string "^1.0.0" + parse-entities "^2.0.0" + repeat-string "^1.0.0" + zwitch "^1.0.0" + +mdast-util-to-string@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz#27055500103f51637bd07d01da01eb1967a43527" + integrity sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A== + mdn-data@2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" @@ -15930,13 +16414,56 @@ microevent.ts@~0.1.1: resolved "https://registry.npmjs.org/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== -micromatch@4.x, micromatch@^4.0.0, micromatch@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" - integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== +micromark-extension-gfm-autolink-literal@~0.5.0: + version "0.5.1" + resolved "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-0.5.1.tgz#5326fc86f3ae0fbba57bb0bfc2f158c9456528ce" + integrity sha512-j30923tDp0faCNDjwqe4cMi+slegbGfc3VEAExEU8d54Q/F6pR6YxCVH+6xV0ItRoj3lCn1XkUWcy6FC3S9BOw== dependencies: - braces "^3.0.1" - picomatch "^2.0.5" + micromark "~2.10.0" + +micromark-extension-gfm-strikethrough@~0.6.0: + version "0.6.2" + resolved "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.2.tgz#754788bdd13046e7f69edaa0d3f3d555d23128d6" + integrity sha512-aehEEqtTn3JekJNwZZxa7ZJVfzmuaWp4ew6x6sl3VAKIwdDZdqYeYSQIrNKwNgH7hX0g56fAwnSDLusJggjlCQ== + dependencies: + micromark "~2.10.0" + +micromark-extension-gfm-table@~0.4.0: + version "0.4.1" + resolved "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-0.4.1.tgz#79cc37da82d6ae0cc3901c1c6264b97a72372fbd" + integrity sha512-xVpqOnfFaa2OtC/Y7rlt4tdVFlUHdoLH3RXAZgb/KP3DDyKsAOx6BRS3UxiiyvmD/p2l6VUpD4bMIniuP4o4JA== + dependencies: + micromark "~2.10.0" + +micromark-extension-gfm-tagfilter@~0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-0.3.0.tgz#d9f26a65adee984c9ccdd7e182220493562841ad" + integrity sha512-9GU0xBatryXifL//FJH+tAZ6i240xQuFrSL7mYi8f4oZSbc+NvXjkrHemeYP0+L4ZUT+Ptz3b95zhUZnMtoi/Q== + +micromark-extension-gfm-task-list-item@~0.3.0: + version "0.3.1" + resolved "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-0.3.1.tgz#24b7f54936a609179595f87a0e5279d1c6cf346c" + integrity sha512-3ZiolwyLEF+t2KvGqKdBNEybiacQCsBgDx4PRZz/dttwo0PkcVKh7jpxc6UdHQuNMJ/YRUNuCSal0WuoAlefAA== + +micromark-extension-gfm@^0.3.0: + version "0.3.1" + resolved "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-0.3.1.tgz#30b8706bd2a3f7fd31aa37873d743946a9e856c3" + integrity sha512-lJlhcOqzoJdjQg+LMumVHdUQ61LjtqGdmZtrAdfvatRUnJTqZlRwXXHdLQgNDYlFw4mycZ4NSTKlya5QcQXl1A== + dependencies: + micromark "~2.10.0" + micromark-extension-gfm-autolink-literal "~0.5.0" + micromark-extension-gfm-strikethrough "~0.6.0" + micromark-extension-gfm-table "~0.4.0" + micromark-extension-gfm-tagfilter "~0.3.0" + micromark-extension-gfm-task-list-item "~0.3.0" + +micromark@~2.10.0: + version "2.10.1" + resolved "https://registry.npmjs.org/micromark/-/micromark-2.10.1.tgz#cd73f54e0656f10e633073db26b663a221a442a7" + integrity sha512-fUuVF8sC1X7wsCS29SYQ2ZfIZYbTymp0EYr6sab3idFjigFFjGa5UwoniPlV9tAgntjuapW1t9U+S0yDYeGKHQ== + dependencies: + debug "^4.0.0" + parse-entities "^2.0.0" micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" @@ -15957,6 +16484,14 @@ micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" +micromatch@^4.0.0, micromatch@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + miller-rabin@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" @@ -15970,18 +16505,6 @@ mime-db@1.44.0, "mime-db@>= 1.43.0 < 2": resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== -mime-db@~1.33.0: - version "1.33.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" - integrity sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ== - -mime-types@2.1.18: - version "2.1.18" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" - integrity sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ== - dependencies: - mime-db "~1.33.0" - mime-types@^2.1.12, mime-types@^2.1.26, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: version "2.1.27" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" @@ -16285,6 +16808,42 @@ msw@^0.20.5: statuses "^2.0.0" yargs "^15.4.1" +msw@^0.21.2: + version "0.21.2" + resolved "https://registry.npmjs.org/msw/-/msw-0.21.2.tgz#74ed10b8eb224325652a3c3812b5460dac297bd8" + integrity sha512-XOJehxtJThNFdMJdVjxDAbZ8KuC3UltOlO5nQDks0Q1yzSUqqKcVUjbKrH7T+K2hckBr0KEY2fwJHv21R4BV2A== + dependencies: + "@open-draft/until" "^1.0.3" + "@types/cookie" "^0.4.0" + chalk "^4.1.0" + chokidar "^3.4.2" + cookie "^0.4.1" + graphql "^15.3.0" + headers-utils "^1.2.0" + node-fetch "^2.6.1" + node-match-path "^0.4.4" + node-request-interceptor "^0.5.1" + statuses "^2.0.0" + yargs "^16.0.3" + +msw@^0.21.3: + version "0.21.3" + resolved "https://registry.npmjs.org/msw/-/msw-0.21.3.tgz#d073842f9570a08f4041806a2c7303a9b8494602" + integrity sha512-voPc/EJsjarvi454vSEuozZQQqLG4AUHT6qQL5Ah47lq7sGCpc7icByeUlfvEj5+MvaugN0c7JwXyCa2rxu8cA== + dependencies: + "@open-draft/until" "^1.0.3" + "@types/cookie" "^0.4.0" + chalk "^4.1.0" + chokidar "^3.4.2" + cookie "^0.4.1" + graphql "^15.3.0" + headers-utils "^1.2.0" + node-fetch "^2.6.1" + node-match-path "^0.4.4" + node-request-interceptor "^0.5.1" + statuses "^2.0.0" + yargs "^16.0.3" + multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" @@ -16613,6 +17172,15 @@ node-request-interceptor@^0.3.5: debug "^4.1.1" headers-utils "^1.2.0" +node-request-interceptor@^0.5.1: + version "0.5.1" + resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.5.1.tgz#b4757a033bde4412d9ffc4503804abb28ed962d2" + integrity sha512-ex5mlI5nGokxocomS2Rj2r1aspmt7qZoI8OvKLt24ylp1bYCzGQ+0XD911guCNDb/kKLMIGC67HHyeFrJCz7jA== + dependencies: + "@open-draft/until" "^1.0.3" + debug "^4.1.1" + headers-utils "^1.2.0" + nodegit@0.27.0, nodegit@^0.27.0: version "0.27.0" resolved "https://registry.npmjs.org/nodegit/-/nodegit-0.27.0.tgz#4e8cc236f60e1c97324a5acff99056fe116a6ebe" @@ -16868,9 +17436,9 @@ object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== object-path@^0.11.4: - version "0.11.4" - resolved "https://registry.npmjs.org/object-path/-/object-path-0.11.4.tgz#370ae752fbf37de3ea70a861c23bba8915691949" - integrity sha1-NwrnUvvzfePqcKhhwju6iRVpGUk= + version "0.11.5" + resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.5.tgz#d4e3cf19601a5140a55a16ad712019a9c50b577a" + integrity sha512-jgSbThcoR/s+XumvGMTMf81QVBmah+/Q7K7YduKeKVWL7N111unR2d6pZZarSk6kY/caeNxUDyxOvMWyzoU2eg== object-visit@^1.0.0: version "1.0.1" @@ -17406,7 +17974,7 @@ parse-asn1@^5.0.0: pbkdf2 "^3.0.3" safe-buffer "^5.1.1" -parse-entities@^1.1.0, parse-entities@^1.1.2: +parse-entities@^1.1.2: version "1.2.2" resolved "https://registry.npmjs.org/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50" integrity sha512-NzfpbxW/NPrzZ/yYSoQxyqUZMZXIdCfE0OIN4ESsnptHJECoUk3FZktxNuzQf4tjt5UEopnxpYJbvYuxIFDdsg== @@ -17600,6 +18168,15 @@ passport-okta-oauth@^0.0.1: pkginfo "0.2.x" uid2 "0.0.3" +passport-onelogin-oauth@^0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/passport-onelogin-oauth/-/passport-onelogin-oauth-0.0.1.tgz#6e991ac6720783fdd80d4caa08c36cc71ef19962" + integrity sha512-EXFBqlJdHf5AX4QaiZsLfhgQUOR6z3zGA5479SUJF4I4rnAt7yasZEbs27pg8MRiQh/uLZEWLGMoVXr6LHV9mQ== + dependencies: + passport-oauth "1.0.0" + pkginfo "0.2.x" + uid2 "0.0.3" + passport-saml@^1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.3.tgz#cbea1a2b21ff32b3bc4bfd84dc39c3a370df9935" @@ -17659,7 +18236,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@1.0.2, path-is-inside@^1.0.2: +path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= @@ -17696,11 +18273,6 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= -path-to-regexp@2.2.1: - version "2.2.1" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" - integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== - path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" @@ -17779,11 +18351,16 @@ pg-connection-string@0.1.3, pg-connection-string@^0.1.3: resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" integrity sha1-2hhHsglA5C7hSSvq9l1J2RskXfc= -pg-connection-string@2.3.0, pg-connection-string@^2.3.0: +pg-connection-string@2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.3.0.tgz#c13fcb84c298d0bfa9ba12b40dd6c23d946f55d6" integrity sha512-ukMTJXLI7/hZIwTW7hGMZJ0Lj0S2XQBCJ4Shv4y1zgQ/vqVea+FLhzywvPj0ujSuofu+yA4MYHGZPTsgjBgJ+w== +pg-connection-string@^2.3.0, pg-connection-string@^2.4.0: + version "2.4.0" + resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.4.0.tgz#c979922eb47832999a204da5dbe1ebf2341b6a10" + integrity sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ== + pg-int8@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" @@ -17797,15 +18374,15 @@ pg-pool@1.*: generic-pool "2.4.3" object-assign "4.1.0" -pg-pool@^3.2.1: - version "3.2.1" - resolved "https://registry.npmjs.org/pg-pool/-/pg-pool-3.2.1.tgz#5f4afc0f58063659aeefa952d36af49fa28b30e0" - integrity sha512-BQDPWUeKenVrMMDN9opfns/kZo4lxmSWhIqo+cSAF7+lfi9ZclQbr9vfnlNaPr8wYF3UYjm5X0yPAhbcgqNOdA== +pg-pool@^3.2.2: + version "3.2.2" + resolved "https://registry.npmjs.org/pg-pool/-/pg-pool-3.2.2.tgz#a560e433443ed4ad946b84d774b3f22452694dff" + integrity sha512-ORJoFxAlmmros8igi608iVEbQNNZlp89diFVx6yV5v+ehmpMY9sK6QgpmgoXbmkNaBAx8cOOZh9g80kJv1ooyA== -pg-protocol@^1.2.5: - version "1.2.5" - resolved "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.2.5.tgz#28a1492cde11646ff2d2d06bdee42a3ba05f126c" - integrity sha512-1uYCckkuTfzz/FCefvavRywkowa6M5FohNMF5OjKrqo9PSR8gYc8poVmwwYQaBxhmQdBjhtP514eXy9/Us2xKg== +pg-protocol@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.3.0.tgz#3c8fb7ca34dbbfcc42776ce34ac5f537d6e34770" + integrity sha512-64/bYByMrhWULUaCd+6/72c9PMWhiVFs3EVxl9Ct6a3v/U8+rKgqP2w+kKg/BIGgMJyB+Bk/eNivT32Al+Jghw== pg-types@1.*: version "1.13.0" @@ -17844,18 +18421,17 @@ pg@^6.1.0: semver "4.3.2" pg@^8.3.0: - version "8.3.0" - resolved "https://registry.npmjs.org/pg/-/pg-8.3.0.tgz#941383300d38eef51ecb88a0188cec441ab64d81" - integrity sha512-jQPKWHWxbI09s/Z9aUvoTbvGgoj98AU7FDCcQ7kdejupn/TcNpx56v2gaOTzXkzOajmOEJEdi9eTh9cA2RVAjQ== + version "8.4.2" + resolved "https://registry.npmjs.org/pg/-/pg-8.4.2.tgz#2aa58166a23391e91d56a7ea57c6d99931c0642a" + integrity sha512-E9FlUrrc7w3+sbRmL1CSw99vifACzB2TjhMM9J5w9D1LIg+6un0jKkpHS1EQf2CWhKhec2bhrBLVMmUBDbjPRQ== dependencies: buffer-writer "2.0.0" packet-reader "1.0.0" - pg-connection-string "^2.3.0" - pg-pool "^3.2.1" - pg-protocol "^1.2.5" + pg-connection-string "^2.4.0" + pg-pool "^3.2.2" + pg-protocol "^1.3.0" pg-types "^2.1.0" pgpass "1.x" - semver "4.3.2" pgpass@1.*, pgpass@1.x: version "1.0.2" @@ -18488,7 +19064,7 @@ pretty-error@^2.1.1: renderkid "^2.0.1" utila "~0.4" -pretty-format@^25.1.0, pretty-format@^25.2.1, pretty-format@^25.5.0: +pretty-format@^25.2.1, pretty-format@^25.5.0: version "25.5.0" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ== @@ -18498,15 +19074,15 @@ pretty-format@^25.1.0, pretty-format@^25.2.1, pretty-format@^25.5.0: ansi-styles "^4.0.0" react-is "^16.12.0" -pretty-format@^26.4.2: - version "26.4.2" - resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.4.2.tgz#d081d032b398e801e2012af2df1214ef75a81237" - integrity sha512-zK6Gd8zDsEiVydOCGLkoBoZuqv8VTiHyAbKznXe/gaph/DAeZOmit9yMfgIz5adIgAMMs5XfoYSwAX3jcCO1tA== +pretty-format@^26.0.0, pretty-format@^26.4.2, pretty-format@^26.5.2, pretty-format@^26.6.1: + version "26.6.1" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8" + integrity sha512-MeqqsP5PYcRBbGMvwzsyBdmAJ4EFX7pWFyl7x4+dMVg5pE0ZDdBIvEH2ergvIO+Gvwv1wh64YuOY9y5LuyY/GA== dependencies: - "@jest/types" "^26.3.0" + "@jest/types" "^26.6.1" ansi-regex "^5.0.0" ansi-styles "^4.0.0" - react-is "^16.12.0" + react-is "^17.0.1" pretty-hrtime@^1.0.3: version "1.0.3" @@ -18559,11 +19135,6 @@ promise-inflight@^1.0.1: resolved "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= -promise-polyfill@^8.1.3: - version "8.1.3" - resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.3.tgz#8c99b3cf53f3a91c68226ffde7bde81d7f904116" - integrity sha512-MG5r82wBzh7pSKDRa9y+vllNHz3e3d4CNj1PQE4BQYxLme0gKYYBm9YENq+UkEikyZ0XbiGWxYlVw3Rl9O/U8g== - promise-retry@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/promise-retry/-/promise-retry-1.1.1.tgz#6739e968e3051da20ce6497fb2b50f6911df3d6d" @@ -18749,7 +19320,7 @@ punycode@1.3.2: resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^1.2.4, punycode@^1.3.2: +punycode@^1.2.4: version "1.4.1" resolved "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= @@ -18794,10 +19365,10 @@ query-string@^4.1.0: object-assign "^4.1.0" strict-uri-encode "^1.0.0" -query-string@^6.12.1: - version "6.13.1" - resolved "https://registry.npmjs.org/query-string/-/query-string-6.13.1.tgz#d913ccfce3b4b3a713989fe6d39466d92e71ccad" - integrity sha512-RfoButmcK+yCta1+FuU8REvisx1oEzhMKwhLUNcepQTPGcNMp1sIqjnfCtfnvGSQZQEhaBHvccujtWoUV3TTbA== +query-string@^6.13.3: + version "6.13.6" + resolved "https://registry.npmjs.org/query-string/-/query-string-6.13.6.tgz#e5ac7c74f2a5da43fbca0b883b4f0bafba439966" + integrity sha512-/WWZ7d9na6s2wMEGdVCVgKWE9Rt7nYyNIf7k8xmHXcesPMlEzicWo3lbYwHyA4wBktI2KrXxxZeACLbE84hvSQ== dependencies: decode-uri-component "^0.2.0" split-on-first "^1.0.0" @@ -18865,16 +19436,6 @@ ramda@^0.26, ramda@~0.26.1: resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== -ramldt2jsonschema@^1.1.0: - version "1.2.3" - resolved "https://registry.npmjs.org/ramldt2jsonschema/-/ramldt2jsonschema-1.2.3.tgz#8d45a7f306a1169a3bde91cab725d1d6d0ff7d55" - integrity sha512-+wLDAV2NNv9NkfEUOYStaDu/6RYgYXeC1zLtXE+dMU/jDfjpN4iJnBGycDwFTFaIQGosOQhxph7fEX6Mpwxdug== - dependencies: - commander "^5.0.0" - js-yaml "^3.14.0" - json-schema-migrate "^0.2.0" - webapi-parser "^0.5.0" - randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -18890,11 +19451,6 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" -range-parser@1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" - integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= - range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -19178,6 +19734,11 @@ react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-i resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.1: + version "17.0.1" + resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" + integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== + react-lazylog@^4.5.2, react-lazylog@^4.5.3: version "4.5.3" resolved "https://registry.npmjs.org/react-lazylog/-/react-lazylog-4.5.3.tgz#289e24995b5599e75943556ac63f5e2c04d0001e" @@ -19198,18 +19759,20 @@ react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: resolved "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== -react-markdown@^4.3.1: - version "4.3.1" - resolved "https://registry.npmjs.org/react-markdown/-/react-markdown-4.3.1.tgz#39f0633b94a027445b86c9811142d05381300f2f" - integrity sha512-HQlWFTbDxTtNY6bjgp3C3uv1h2xcjCSi1zAEzfBW9OwJJvENSYiLXWNXN5hHLsoqai7RnZiiHzcnWdXk2Splzw== +react-markdown@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/react-markdown/-/react-markdown-5.0.2.tgz#d15a8beb37b4ec34fc23dd892e7755eb7040b8db" + integrity sha512-kmkB4JbV7LqkDAjvaKRKtodB3n3Id76/DalaDun1U8FuLB0SenPfvH+jAQ5Pcpo54cACRQc1LB1yXmuuuIVecw== dependencies: + "@types/mdast" "^3.0.3" + "@types/unist" "^2.0.3" html-to-react "^1.3.4" mdast-add-list-metadata "1.0.1" prop-types "^15.7.2" react-is "^16.8.6" - remark-parse "^5.0.0" - unified "^6.1.5" - unist-util-visit "^1.3.0" + remark-parse "^9.0.0" + unified "^9.0.0" + unist-util-visit "^2.0.0" xtend "^4.0.1" react-motion@^0.5.2: @@ -19313,7 +19876,7 @@ react-router@5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react-router@6.0.0-beta.0: +react-router@6.0.0-beta.0, react-router@^6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-beta.0.tgz#3e11f39b6ded4412c2fed9e4f989dd4c8156724d" integrity sha512-VgMdfpVcmFQki/LZuLh8E/MNACekDetz4xqft+a6fBZvvJnVqKbLqebF7hyoawGrV1HcO5tVaUang2Og4W2j1Q== @@ -19882,6 +20445,11 @@ regjsparser@^0.6.4: dependencies: jsesc "~0.5.0" +regression@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/regression/-/regression-2.0.1.tgz#8d29c3e8224a10850c35e337e85a8b2fac3b0c87" + integrity sha1-jSnD6CJKEIUMNeM36FqLL6w7DIc= + relateurl@^0.2.7: version "0.2.7" resolved "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" @@ -19917,26 +20485,20 @@ relay-runtime@10.0.1: "@babel/runtime" "^7.0.0" fbjs "^1.0.0" -remark-parse@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/remark-parse/-/remark-parse-5.0.0.tgz#4c077f9e499044d1d5c13f80d7a98cf7b9285d95" - integrity sha512-b3iXszZLH1TLoyUzrATcTQUZrwNl1rE70rVdSruJFlDaJ9z5aMkhrG43Pp68OgfHndL/ADz6V69Zow8cTQu+JA== +remark-gfm@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/remark-gfm/-/remark-gfm-1.0.0.tgz#9213643001be3f277da6256464d56fd28c3b3c0d" + integrity sha512-KfexHJCiqvrdBZVbQ6RopMZGwaXz6wFJEfByIuEwGf0arvITHjiKKZ1dpXujjH9KZdm1//XJQwgfnJ3lmXaDPA== dependencies: - collapse-white-space "^1.0.2" - is-alphabetical "^1.0.0" - is-decimal "^1.0.0" - is-whitespace-character "^1.0.0" - is-word-character "^1.0.0" - markdown-escapes "^1.0.0" - parse-entities "^1.1.0" - repeat-string "^1.5.4" - state-toggle "^1.0.0" - trim "0.0.1" - trim-trailing-lines "^1.0.0" - unherit "^1.0.4" - unist-util-remove-position "^1.0.0" - vfile-location "^2.0.0" - xtend "^4.0.1" + mdast-util-gfm "^0.1.0" + micromark-extension-gfm "^0.3.0" + +remark-parse@^9.0.0: + version "9.0.0" + resolved "https://registry.npmjs.org/remark-parse/-/remark-parse-9.0.0.tgz#4d20a299665880e4f4af5d90b7c7b8a935853640" + integrity sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw== + dependencies: + mdast-util-from-markdown "^0.8.0" remarkable@^2.0.1: version "2.0.1" @@ -19977,7 +20539,7 @@ repeat-element@^1.1.2: resolved "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== -repeat-string@^1.5.2, repeat-string@^1.5.4, repeat-string@^1.6.1: +repeat-string@^1.0.0, repeat-string@^1.5.2, repeat-string@^1.6.1: version "1.6.1" resolved "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= @@ -20139,7 +20701,7 @@ resolve-url@^0.2.1: resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@1.17.0, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.11.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.15.1, resolve@^1.16.1, resolve@^1.17.0, resolve@^1.3.2: +resolve@1.17.0, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.15.1, resolve@^1.16.1, resolve@^1.17.0, resolve@^1.3.2: version "1.17.0" resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== @@ -20260,10 +20822,10 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -rollup-plugin-dts@1.4.11: - version "1.4.11" - resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.11.tgz#aedf0b7bb91d51e20b755e2c18e840edfc7af7a1" - integrity sha512-yiScAMKgwH77b44a/IFGgjLsmwSlNfQhEM+eCb2uMrupQMPE1n/12wrnT431+v1u6wYMF1XuHqldh+v/7mTvYA== +rollup-plugin-dts@1.4.13: + version "1.4.13" + resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.13.tgz#4f086e84f4fdcc1f49160799ebc66f6b09db292b" + integrity sha512-7mxoQ6PcmCkBE5ZhrjGDL4k42XLy8BkSqpiRi1MipwiGs+7lwi4mQkp2afX+OzzLjJp/TGM8llfe8uayIUhPEw== optionalDependencies: "@babel/code-frame" "^7.10.4" @@ -20588,20 +21150,6 @@ serve-favicon@^2.5.0: parseurl "~1.3.2" safe-buffer "5.1.1" -serve-handler@^6.1.3: - version "6.1.3" - resolved "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.3.tgz#1bf8c5ae138712af55c758477533b9117f6435e8" - integrity sha512-FosMqFBNrLyeiIDvP1zgO6YoTzFYHxLDEIavhlmQ+knB2Z7l1t+kGLHkZIDN7UVWqQAmKI3D20A6F6jo3nDd4w== - dependencies: - bytes "3.0.0" - content-disposition "0.5.2" - fast-url-parser "1.1.3" - mime-types "2.1.18" - minimatch "3.0.4" - path-is-inside "1.0.2" - path-to-regexp "2.2.1" - range-parser "1.2.0" - serve-index@^1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -20952,7 +21500,7 @@ source-list-map@^2.0.0: resolved "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== -source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: +source-map-resolve@^0.5.0: version "0.5.3" resolved "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== @@ -20963,6 +21511,14 @@ source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: source-map-url "^0.4.0" urix "^0.1.0" +source-map-resolve@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2" + integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + source-map-support@^0.5.16, source-map-support@^0.5.17, source-map-support@^0.5.6, source-map-support@~0.5.12: version "0.5.19" resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" @@ -21240,11 +21796,6 @@ start-server-webpack-plugin@^2.2.5: resolved "https://registry.npmjs.org/start-server-webpack-plugin/-/start-server-webpack-plugin-2.2.5.tgz#4a2838759b0f36acd11b0b2f5f196f289ae29d31" integrity sha512-DRCkciwCJoCFZ+wt3wWMkR1M2mpVhJbUKFXqhK3FWyIUKYb42NnocH5sMwqgo+nPNHupqNwK/v8lgfBbr2NKdg== -state-toggle@^1.0.0: - version "1.0.3" - resolved "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz#e123b16a88e143139b09c6852221bc9815917dfe" - integrity sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ== - static-extend@^0.1.1: version "0.1.2" resolved "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -21558,10 +22109,10 @@ strip-indent@^3.0.0: dependencies: min-indent "^1.0.0" -strip-json-comments@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz#7638d31422129ecf4457440009fba03f9f9ac180" - integrity sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w== +strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== strip-json-comments@~2.0.1: version "2.0.1" @@ -21645,10 +22196,10 @@ subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.16: symbol-observable "^1.0.4" ws "^5.2.0" -sucrase@^3.14.1: - version "3.15.0" - resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.15.0.tgz#78596a78be7264a65b52ed8d873883413ef0220c" - integrity sha512-05TJOUfMgckH7wKqfk/1p4G6q16nIeW/GHQwD44vkT0mQMqqzgfHCwkX3whNmwyOo7nVF0jDLwVu/qOBTtsscw== +sucrase@^3.16.0: + version "3.16.0" + resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.16.0.tgz#19b5b886ccca270dd5ca12ff060eeaf0b599735f" + integrity sha512-ovVuswxV5TayCPXfTk8bgBgk6uNRvsinIkEpq0J6zS1xXCx5N/LLGcbsKdRhqn/ToZylMX6+yXaR1LSn1I42Pg== dependencies: commander "^4.0.0" glob "7.1.6" @@ -21919,7 +22470,7 @@ tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: safe-buffer "^5.1.2" yallist "^3.0.3" -tar@^6.0.1, tar@^6.0.2: +tar@^6.0.1, tar@^6.0.2, tar@^6.0.5: version "6.0.5" resolved "https://registry.npmjs.org/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f" integrity sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg== @@ -21931,10 +22482,10 @@ tar@^6.0.1, tar@^6.0.2: mkdirp "^1.0.3" yallist "^4.0.0" -tarn@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.0.tgz#a4082405216c0cce182b8b4cb2639c52c1e870d4" - integrity sha512-PKUnlDFODZueoA8owLehl8vLcgtA8u4dRuVbZc92tspDYZixjJL6TqYOmryf/PfP/EBX+2rgNcrj96NO+RPkdQ== +tarn@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.1.tgz#ebac2c6dbc6977d34d4526e0a7814200386a8aec" + integrity sha512-6usSlV9KyHsspvwu2duKH+FMUhqJnAh6J5J/4MITl8s94iSUQTLkJggdiewKv4RyARQccnigV48Z+khiuVZDJw== tdigest@^0.1.1: version "0.1.1" @@ -22356,16 +22907,6 @@ trim-off-newlines@^1.0.0: resolved "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz#9f9ba9d9efa8764c387698bcbfeb2c848f11adb3" integrity sha1-n5up2e+odkw4dpi8v+sshI8RrbM= -trim-trailing-lines@^1.0.0: - version "1.1.3" - resolved "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-1.1.3.tgz#7f0739881ff76657b7776e10874128004b625a94" - integrity sha512-4ku0mmjXifQcTVfYDfR5lpgV7zVqPg6zV9rdZmwOPqq0+Zq19xDqEgagqVbc4pOOShbncuAOIs59R3+3gcF3ZA== - -trim@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" - integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0= - triple-beam@^1.2.0, triple-beam@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" @@ -22408,21 +22949,23 @@ ts-invariant@^0.4.0: dependencies: tslib "^1.9.3" -ts-jest@^26.0.0: - version "26.1.1" - resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.1.1.tgz#b98569b8a4d4025d966b3d40c81986dd1c510f8d" - integrity sha512-Lk/357quLg5jJFyBQLnSbhycnB3FPe+e9i7ahxokyXxAYoB0q1pPmqxxRPYr4smJic1Rjcf7MXDBhZWgxlli0A== +ts-jest@^26.4.3: + version "26.4.3" + resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.4.3.tgz#d153a616033e7ec8544b97ddbe2638cbe38d53db" + integrity sha512-pFDkOKFGY+nL9v5pkhm+BIFpoAuno96ff7GMnIYr/3L6slFOS365SI0fGEVYx2RKGji5M2elxhWjDMPVcOCdSw== dependencies: + "@jest/create-cache-key-function" "^26.5.0" + "@types/jest" "26.x" bs-logger "0.x" buffer-from "1.x" fast-json-stable-stringify "2.x" + jest-util "^26.1.0" json5 "2.x" lodash.memoize "4.x" make-error "1.x" - micromatch "4.x" mkdirp "1.x" semver "7.x" - yargs-parser "18.x" + yargs-parser "20.x" ts-loader@^7.0.4: version "7.0.4" @@ -22615,10 +23158,21 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^3.9.3: - version "3.9.7" - resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz#98d600a5ebdc38f40cb277522f12dc800e9e25fa" - integrity sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw== +typescript-json-schema@^0.43.0: + version "0.43.0" + resolved "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.43.0.tgz#8bd9c832f1f15f006ff933907ce192222fdfd92f" + integrity sha512-4c9IMlIlHYJiQtzL1gh2nIPJEjBgJjDUs50gsnnc+GFyDSK1oFM3uQIBSVosiuA/4t6LSAXDS9vTdqbQC6EcgA== + dependencies: + "@types/json-schema" "^7.0.5" + glob "~7.1.6" + json-stable-stringify "^1.0.1" + typescript "~4.0.2" + yargs "^15.4.1" + +typescript@^4.0.3, typescript@~4.0.2: + version "4.0.5" + resolved "https://registry.npmjs.org/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389" + integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ== ua-parser-js@^0.7.18: version "0.7.21" @@ -22680,14 +23234,6 @@ unfetch@^4.1.0: resolved "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz#6ec2dd0de887e58a4dee83a050ded80ffc4137db" integrity sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg== -unherit@^1.0.4: - version "1.1.3" - resolved "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22" - integrity sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ== - dependencies: - inherits "^2.0.0" - xtend "^4.0.0" - unicode-canonical-property-names-ecmascript@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" @@ -22711,17 +23257,17 @@ unicode-property-aliases-ecmascript@^1.0.4: resolved "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4" integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg== -unified@^6.1.5: - version "6.2.0" - resolved "https://registry.npmjs.org/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba" - integrity sha512-1k+KPhlVtqmG99RaTbAv/usu85fcSRu3wY8X+vnsEhIxNP5VbVIDiXnLqyKIG+UMdyTg0ZX9EI6k2AfjJkHPtA== +unified@^9.0.0: + version "9.2.0" + resolved "https://registry.npmjs.org/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8" + integrity sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg== dependencies: bail "^1.0.0" extend "^3.0.0" - is-plain-obj "^1.1.0" + is-buffer "^2.0.0" + is-plain-obj "^2.0.0" trough "^1.0.0" - vfile "^2.0.0" - x-is-string "^0.1.0" + vfile "^4.0.0" union-value@^1.0.0: version "1.0.1" @@ -22764,41 +23310,39 @@ unique-string@^2.0.0: dependencies: crypto-random-string "^2.0.0" -unist-util-is@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/unist-util-is/-/unist-util-is-3.0.0.tgz#d9e84381c2468e82629e4a5be9d7d05a2dd324cd" - integrity sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A== +unist-util-is@^4.0.0: + version "4.0.3" + resolved "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.0.3.tgz#e8b44db55fc20c43752b3346c116344d45d7c91d" + integrity sha512-bTofCFVx0iQM8Jqb1TBDVRIQW03YkD3p66JOd/aCWuqzlLyUtx1ZAGw/u+Zw+SttKvSVcvTiKYbfrtLoLefykw== -unist-util-remove-position@^1.0.0: - version "1.1.4" - resolved "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-1.1.4.tgz#ec037348b6102c897703eee6d0294ca4755a2020" - integrity sha512-tLqd653ArxJIPnKII6LMZwH+mb5q+n/GtXQZo6S6csPRs5zB0u79Yw8ouR3wTw8wxvdJFhpP6Y7jorWdCgLO0A== +unist-util-stringify-position@^2.0.0: + version "2.0.3" + resolved "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz#cce3bfa1cdf85ba7375d1d5b17bdc4cada9bd9da" + integrity sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g== dependencies: - unist-util-visit "^1.1.0" - -unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: - version "1.1.2" - resolved "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6" - integrity sha512-pNCVrk64LZv1kElr0N1wPiHEUoXNVFERp+mlTg/s9R5Lwg87f9bM/3sQB99w+N9D/qnM9ar3+AKDBwo/gm/iQQ== + "@types/unist" "^2.0.2" unist-util-visit-parents@1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-1.1.2.tgz#f6e3afee8bdbf961c0e6f028ea3c0480028c3d06" integrity sha512-yvo+MMLjEwdc3RhhPYSximset7rwjMrdt9E41Smmvg25UQIenzrN83cRnF1JMzoMi9zZOQeYXHSDf7p+IQkW3Q== -unist-util-visit-parents@^2.0.0: - version "2.1.2" - resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-2.1.2.tgz#25e43e55312166f3348cae6743588781d112c1e9" - integrity sha512-DyN5vD4NE3aSeB+PXYNKxzGsfocxp6asDc2XXE3b0ekO2BaRUpBicbbUygfSvYfUz1IkmjFR1YF7dPklraMZ2g== +unist-util-visit-parents@^3.0.0: + version "3.1.1" + resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz#65a6ce698f78a6b0f56aa0e88f13801886cdaef6" + integrity sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg== dependencies: - unist-util-is "^3.0.0" + "@types/unist" "^2.0.0" + unist-util-is "^4.0.0" -unist-util-visit@^1.1.0, unist-util-visit@^1.3.0: - version "1.4.1" - resolved "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3" - integrity sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw== +unist-util-visit@^2.0.0: + version "2.0.3" + resolved "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz#c3703893146df47203bb8a9795af47d7b971208c" + integrity sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q== dependencies: - unist-util-visit-parents "^2.0.0" + "@types/unist" "^2.0.0" + unist-util-is "^4.0.0" + unist-util-visit-parents "^3.0.0" universal-user-agent@^4.0.0: version "4.0.1" @@ -22890,7 +23434,7 @@ upper-case@2.0.1, upper-case@^2.0.1: dependencies: tslib "^1.10.0" -uri-js@^4.2.1, uri-js@^4.2.2: +uri-js@^4.2.2: version "4.2.2" resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== @@ -23049,10 +23593,10 @@ v8-compile-cache@^2.0.3: resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== -v8-to-istanbul@^5.0.1: - version "5.0.1" - resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-5.0.1.tgz#0608f5b49a481458625edb058488607f25498ba5" - integrity sha512-mbDNjuDajqYe3TXFk5qxcQy8L1msXNE37WTlLoqqpBfRsimbNcrlhQlDPntmECEcUvdC+AQ8CyMMf6EUx1r74Q== +v8-to-istanbul@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-6.0.1.tgz#7ef0e32faa10f841fe4c1b0f8de96ed067c0be1e" + integrity sha512-PzM1WlqquhBvsV+Gco6WSFeg1AGdD53ccMRkFeyHRE/KRZaVacPOmQYP3EeVgDBtKD2BJ8kgynBQ5OtKiHCH+w== dependencies: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" @@ -23146,27 +23690,24 @@ verror@1.10.0, verror@^1.8.1: core-util-is "1.0.2" extsprintf "^1.2.0" -vfile-location@^2.0.0: - version "2.0.6" - resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-2.0.6.tgz#8a274f39411b8719ea5728802e10d9e0dff1519e" - integrity sha512-sSFdyCP3G6Ka0CEmN83A2YCMKIieHx0EDaj5IDP4g1pa5ZJ4FJDvpO0WODLxo4LUX4oe52gmSCK7Jw4SBghqxA== - -vfile-message@^1.0.0: - version "1.1.1" - resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-1.1.1.tgz#5833ae078a1dfa2d96e9647886cd32993ab313e1" - integrity sha512-1WmsopSGhWt5laNir+633LszXvZ+Z/lxveBf6yhGsqnQIhlhzooZae7zV6YVM1Sdkw68dtAW3ow0pOdPANugvA== +vfile-message@^2.0.0: + version "2.0.4" + resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz#5b43b88171d409eae58477d13f23dd41d52c371a" + integrity sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ== dependencies: - unist-util-stringify-position "^1.1.1" + "@types/unist" "^2.0.0" + unist-util-stringify-position "^2.0.0" -vfile@^2.0.0: - version "2.3.0" - resolved "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a" - integrity sha512-ASt4mBUHcTpMKD/l5Q+WJXNtshlWxOogYyGYYrg4lt/vuRjC1EFQtlAofL5VmtVNIZJzWYFJjzGWZ0Gw8pzW1w== +vfile@^4.0.0: + version "4.2.0" + resolved "https://registry.npmjs.org/vfile/-/vfile-4.2.0.tgz#26c78ac92eb70816b01d4565e003b7e65a2a0e01" + integrity sha512-a/alcwCvtuc8OX92rqqo7PflxiCgXRFjdyoGVuYV+qbgCb0GgZJRvIgCD4+U/Kl1yhaRsaTwksF88xbPyGsgpw== dependencies: - is-buffer "^1.1.4" + "@types/unist" "^2.0.0" + is-buffer "^2.0.0" replace-ext "1.0.0" - unist-util-stringify-position "^1.0.0" - vfile-message "^1.0.0" + unist-util-stringify-position "^2.0.0" + vfile-message "^2.0.0" vm-browserify@^1.0.1: version "1.1.2" @@ -23257,13 +23798,6 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" -webapi-parser@^0.5.0: - version "0.5.0" - resolved "https://registry.npmjs.org/webapi-parser/-/webapi-parser-0.5.0.tgz#2632185c5d8f3e6addb2520857af89ea9844dc14" - integrity sha512-fPt6XuMqLSvBz8exwX4QE1UT+pROLHa00EMDCdO0ybICduwQ1V4f7AWX4pNOpCp+x+0FjczEsOxtQU0d8L3QKw== - dependencies: - ajv "6.5.2" - webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -23274,7 +23808,7 @@ webidl-conversions@^5.0.0: resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== -webidl-conversions@^6.0.0: +webidl-conversions@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== @@ -23456,7 +23990,7 @@ whatwg-fetch@^2.0.4: resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== -whatwg-fetch@^3.0.0, whatwg-fetch@^3.4.0, whatwg-fetch@^3.4.1: +whatwg-fetch@^3.0.0, whatwg-fetch@^3.4.1: version "3.4.1" resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz#e5f871572d6879663fa5674c8f833f15a8425ab3" integrity sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ== @@ -23740,11 +24274,6 @@ ws@^7.3.1: resolved "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8" integrity sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA== -x-is-string@^0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82" - integrity sha1-R0tQhlrzpJqcRlfwWs0UVFj3fYI= - xcase@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/xcase/-/xcase-2.0.1.tgz#c7fa72caa0f440db78fd5673432038ac984450b9" @@ -23883,13 +24412,10 @@ yaml@*, yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== -yargs-parser@18.x, yargs-parser@^18.1.2: - version "18.1.3" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" - integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" +yargs-parser@20.x: + version "20.2.3" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.3.tgz#92419ba867b858c868acf8bae9bf74af0dd0ce26" + integrity sha512-emOFRT9WVHw03QSvN5qor9QQT9+sw5vwxfYweivSMHTcAXPefwVae2FjO7JJjj8hCE4CzPOPeFM83VwT29HCww== yargs-parser@^10.0.0: version "10.1.0" @@ -23914,6 +24440,14 @@ yargs-parser@^15.0.1: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs-parser@^20.0.0: version "20.2.1" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.1.tgz#28f3773c546cdd8a69ddae68116b48a5da328e77" @@ -24036,7 +24570,7 @@ yn@^4.0.0: resolved "https://registry.npmjs.org/yn/-/yn-4.0.0.tgz#611480051ea43b510da1dfdbe177ed159f00a979" integrity sha512-huWiiCS4TxKc4SfgmTwW1K7JmXPPAmuXWYy4j9qjQo4+27Kni8mGhAAi1cloRWmBe2EqcLgt3IGqQoRL/MtPgg== -yup@^0.29.1: +yup@^0.29.3: version "0.29.3" resolved "https://registry.npmjs.org/yup/-/yup-0.29.3.tgz#69a30fd3f1c19f5d9e31b1cf1c2b851ce8045fea" integrity sha512-RNUGiZ/sQ37CkhzKFoedkeMfJM0vNQyaz+wRZJzxdKE7VfDeVKH8bb4rr7XhRLbHJz5hSjoDNwMEIaKhuMZ8gQ== @@ -24084,3 +24618,8 @@ zombie@^6.1.4: request "^2.85.0" tough-cookie "^2.3.4" ws "^6.1.2" + +zwitch@^1.0.0: + version "1.0.5" + resolved "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" + integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==