diff --git a/.changeset/1724.md b/.changeset/1724.md new file mode 100644 index 0000000000..2ac03f94e9 --- /dev/null +++ b/.changeset/1724.md @@ -0,0 +1,27 @@ +--- +'@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 new file mode 100644 index 0000000000..655cc5a3e8 --- /dev/null +++ b/.changeset/2284.md @@ -0,0 +1,11 @@ +--- +'@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 new file mode 100644 index 0000000000..32f5edd6b7 --- /dev/null +++ b/.changeset/2515.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cloudbuild': minor +--- + +Releasing Google Cloud Build Plugin diff --git a/.changeset/2532.md b/.changeset/2532.md new file mode 100644 index 0000000000..14307f6c08 --- /dev/null +++ b/.changeset/2532.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': minor +--- + +Add handling and docs for entity references diff --git a/.changeset/2535.md b/.changeset/2535.md new file mode 100644 index 0000000000..bd28f3ce03 --- /dev/null +++ b/.changeset/2535.md @@ -0,0 +1,14 @@ +--- +'@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 new file mode 100644 index 0000000000..1ba29da040 --- /dev/null +++ b/.changeset/2541.md @@ -0,0 +1,5 @@ +--- +'@backstage/theme': minor +--- + +Tweak dark mode colors diff --git a/.changeset/2543.md b/.changeset/2543.md new file mode 100644 index 0000000000..00311ade45 --- /dev/null +++ b/.changeset/2543.md @@ -0,0 +1,9 @@ +--- +'@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 new file mode 100644 index 0000000000..716f370ad9 --- /dev/null +++ b/.changeset/2562.md @@ -0,0 +1,7 @@ +--- +'@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 new file mode 100644 index 0000000000..318d5cf5ef --- /dev/null +++ b/.changeset/2563.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 0000000000..92253101ee --- /dev/null +++ b/.changeset/2565.md @@ -0,0 +1,59 @@ +--- +'@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 new file mode 100644 index 0000000000..b08f50ed13 --- /dev/null +++ b/.changeset/2575.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': minor +--- + +Fix dense in Structured Metadata Table diff --git a/.changeset/2586.md b/.changeset/2586.md new file mode 100644 index 0000000000..568adb8a3e --- /dev/null +++ b/.changeset/2586.md @@ -0,0 +1,6 @@ +--- +'@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 new file mode 100644 index 0000000000..910de1ca04 --- /dev/null +++ b/.changeset/2587.md @@ -0,0 +1,9 @@ +--- +'@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 new file mode 100644 index 0000000000..9db25e8d0e --- /dev/null +++ b/.changeset/2597.md @@ -0,0 +1,6 @@ +--- +'@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 new file mode 100644 index 0000000000..03e10e7db8 --- /dev/null +++ b/.changeset/2598.md @@ -0,0 +1,11 @@ +--- +'@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 new file mode 100644 index 0000000000..6fc9775fa1 --- /dev/null +++ b/.changeset/2600.md @@ -0,0 +1,19 @@ +--- +'@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 new file mode 100644 index 0000000000..c24d2ea033 --- /dev/null +++ b/.changeset/2603.md @@ -0,0 +1,8 @@ +--- +'@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 new file mode 100644 index 0000000000..f6059e7705 --- /dev/null +++ b/.changeset/2606.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Move auth provider router creation to router diff --git a/.changeset/2609.md b/.changeset/2609.md new file mode 100644 index 0000000000..6bb8ab7a40 --- /dev/null +++ b/.changeset/2609.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Sync scaffolded backend with example diff --git a/.changeset/2610.md b/.changeset/2610.md new file mode 100644 index 0000000000..92ec8dcc0c --- /dev/null +++ b/.changeset/2610.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-circleci': minor +--- + +Refactor to use DiscoveryApi diff --git a/.changeset/2611.md b/.changeset/2611.md new file mode 100644 index 0000000000..cbf3129038 --- /dev/null +++ b/.changeset/2611.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Remove discovery api override diff --git a/.changeset/2612.md b/.changeset/2612.md new file mode 100644 index 0000000000..bf093d8f77 --- /dev/null +++ b/.changeset/2612.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins': patch +--- + +Refactor to use DiscoveryApi diff --git a/.changeset/2613.md b/.changeset/2613.md new file mode 100644 index 0000000000..cf8c1a155a --- /dev/null +++ b/.changeset/2613.md @@ -0,0 +1,12 @@ +--- +'@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 new file mode 100644 index 0000000000..7e2660bc80 --- /dev/null +++ b/.changeset/2614.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': minor +--- + +Default to using internal scope for new plugins diff --git a/.changeset/2615.md b/.changeset/2615.md new file mode 100644 index 0000000000..412b8127c4 --- /dev/null +++ b/.changeset/2615.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 0000000000..9a70c0e79f --- /dev/null +++ b/.changeset/2616.md @@ -0,0 +1,7 @@ +--- +'@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 new file mode 100644 index 0000000000..abd5ce7a4c --- /dev/null +++ b/.changeset/2623.md @@ -0,0 +1,7 @@ +--- +'@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 new file mode 100644 index 0000000000..dcfc800713 --- /dev/null +++ b/.changeset/2624.md @@ -0,0 +1,17 @@ +--- +'@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 new file mode 100644 index 0000000000..bcd49dddb2 --- /dev/null +++ b/.changeset/2625-catalog-backend.md @@ -0,0 +1,9 @@ +--- +'@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 new file mode 100644 index 0000000000..c5a5437b69 --- /dev/null +++ b/.changeset/2625-cli.md @@ -0,0 +1,7 @@ +--- +'@backstage/cli': patch +--- + +Add codeowners processor + +- Include ESNext.Promise in TypeScript compilation diff --git a/.changeset/2628.md b/.changeset/2628.md new file mode 100644 index 0000000000..0ac36d456a --- /dev/null +++ b/.changeset/2628.md @@ -0,0 +1,22 @@ +--- +'@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 new file mode 100644 index 0000000000..933204e292 --- /dev/null +++ b/.changeset/2630.md @@ -0,0 +1,9 @@ +--- +'@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 new file mode 100644 index 0000000000..9d368634b6 --- /dev/null +++ b/.changeset/2637.md @@ -0,0 +1,21 @@ +--- +'@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 new file mode 100644 index 0000000000..e6a623088e --- /dev/null +++ b/.changeset/2639.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': minor +--- + +Upgrade dependency `esbuild@0.7.7` diff --git a/.changeset/2641.md b/.changeset/2641.md new file mode 100644 index 0000000000..3b06358e81 --- /dev/null +++ b/.changeset/2641.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Simplify the read function in processors diff --git a/.changeset/2656.md b/.changeset/2656.md new file mode 100644 index 0000000000..e253b6b1f6 --- /dev/null +++ b/.changeset/2656.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +Lookup user in Google Auth Provider diff --git a/.changeset/2657.md b/.changeset/2657.md new file mode 100644 index 0000000000..9aa645d65f --- /dev/null +++ b/.changeset/2657.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': minor +--- + +Added EmptyState component diff --git a/.changeset/2660.md b/.changeset/2660.md new file mode 100644 index 0000000000..758d523f07 --- /dev/null +++ b/.changeset/2660.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Resolve some dark mode styling issues in asyncAPI specs diff --git a/.changeset/2661.md b/.changeset/2661.md new file mode 100644 index 0000000000..e73b0abf26 --- /dev/null +++ b/.changeset/2661.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Fixed banner component position in DismissableBanner component diff --git a/.changeset/2669-catalog-backend.md b/.changeset/2669-catalog-backend.md new file mode 100644 index 0000000000..32ee5c0f4d --- /dev/null +++ b/.changeset/2669-catalog-backend.md @@ -0,0 +1,7 @@ +--- +'@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 new file mode 100644 index 0000000000..b9a2841a1b --- /dev/null +++ b/.changeset/2669-catalog-model.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 0000000000..e7075ffeb8 --- /dev/null +++ b/.changeset/2669-create-app.md @@ -0,0 +1,7 @@ +--- +'@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 new file mode 100644 index 0000000000..15a7a0e849 --- /dev/null +++ b/.changeset/2674.md @@ -0,0 +1,12 @@ +--- +'@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 new file mode 100644 index 0000000000..8831c1309b --- /dev/null +++ b/.changeset/2686.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Update SSR template to pass CI diff --git a/.changeset/2689.md b/.changeset/2689.md new file mode 100644 index 0000000000..be75dd261b --- /dev/null +++ b/.changeset/2689.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Upgrade dependency rollup-plugin-typescript2 to ^0.27.3 diff --git a/.changeset/2722.md b/.changeset/2722.md new file mode 100644 index 0000000000..69642b5797 --- /dev/null +++ b/.changeset/2722.md @@ -0,0 +1,11 @@ +--- +'@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/README.md b/.changeset/README.md new file mode 100644 index 0000000000..4f3b76b096 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000000..c7c4f11e57 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,58 @@ +{ + "$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" + ] + ], + "access": "public", + "baseBranch": "master", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.changeset/create-app-url-reader-update.md b/.changeset/create-app-url-reader-update.md new file mode 100644 index 0000000000..eb2e538b77 --- /dev/null +++ b/.changeset/create-app-url-reader-update.md @@ -0,0 +1,6 @@ +--- +'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/metal-fishes-learn.md b/.changeset/metal-fishes-learn.md new file mode 100644 index 0000000000..d22af83ca0 --- /dev/null +++ b/.changeset/metal-fishes-learn.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 0000000000..94758c5bc1 --- /dev/null +++ b/.changeset/new-url-reader.md @@ -0,0 +1,12 @@ +--- +'@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 new file mode 100644 index 0000000000..72cb059979 --- /dev/null +++ b/.changeset/short-secrets.md @@ -0,0 +1,5 @@ +--- +'@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 new file mode 100644 index 0000000000..40fef3c106 --- /dev/null +++ b/.changeset/url-reader-processor.md @@ -0,0 +1,5 @@ +--- +'@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/.codecov.yml b/.codecov.yml index bbe2ee8e93..1b8e770b59 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -6,3 +6,15 @@ coverage: default: threshold: 0% # Ref: https://docs.codecov.io/docs/codecovyml-reference#coveragestatus target: auto + +# Since Backstage is a mono repo, flags here help in getting the code coverage of individual packages. +# Documentation: https://docs.codecov.io/docs/flags +flags: + core: + paths: + - packages/core/ + carryforward: true + core-api: + paths: + - packages/core-api/ + carryforward: true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index de0655cdbc..3df1031034 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,8 @@ * @spotify/backstage-core /docs/features/techdocs @spotify/techdocs-core +/plugins/cost-insights @spotify/silver-lining +/plugins/cloudbuild @trivago/ebarrios /plugins/techdocs @spotify/techdocs-core /plugins/techdocs-backend @spotify/techdocs-core /packages/techdocs-cli @spotify/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt new file mode 100644 index 0000000000..21045f04dd --- /dev/null +++ b/.github/styles/vocab.txt @@ -0,0 +1,203 @@ +abc +Apdex +api +Api +apis +args +asciidoc +async +Avro +backrub +Balachandran +Bigtable +Blackbox +bool +boolean +Chai +changeset +changesets +Changesets +changset +chanwit +Chanwit +cisphobia +cissexist +classname +cli +cncf +codeblocks +Codecov +codehilite +Codehilite +codeowners +config +Config +configs +const +cookiecutter +css +dariddler +deadnaming +destructured +dev +devs +discoverability +Discoverability +dls +docgen +Dockerfile +Dockerize +dockerode +Docusaurus +eg +Ek +env +Env +eslint +facto +failover +Figma +Firekube +Fredrik +github +Github +Gitlab +graphql +graphviz +Hackathons +haproxy +heroku +Hostname +http +https +img +incentivised +inlinehilite +interop +javascript +Javascript +jq +js +json +jsx +Kaewkasi +Knex +kubectl +kubernetes +learnings +lerna +Lerna +magiclink +mailto +Malus +md +microsite +middleware +minikube +Minikube +misgendering +mkdocs +Mkdocs +monorepo +Monorepo +monorepos +msw +namespace +Namespaces +neuro +newrelic +nginx +Niklas +nohoist +nonces +npm +nvm +oauth +Oauth +Okta +Oldsberg +onboarding +Onboarding +pagerduty +Patrik +Phoen +plantuml +Pomaceous +postgres +pre +prebaked +preconfigured +Preprarer +Prerequisities +productional +Protobuf +proxying +Proxying +pygments +pymdownx +Raghunandan +rankdir +readme +Readme +Redash +repo +Repo +repos +rerender +rollbar +Rollbar +Rollup +Rosaceae +rst +rsync +ruleset +sam +scaffolded +scaffolder +Scaffolder +semlas +Serverless +Sinon +smartsymobls +sparklines +Spotifiers +spotify +Spotify +squidfunk +src +subkey +superfences +Superfences +talkdesk +Talkdesk +tasklist +techdocs +templated +templater +Templater +templaters +Templaters +Thauer +theres +toc +tolerations +Tolerations +toolsets +touchpoints +ui +upvote +url +utils +validators +Voi +Wealthsimple +Weaveworks +xyz +yaml +Zalando +Zhou +Billett +cloudbuild +Grafana +Iain +Snyk diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml new file mode 100644 index 0000000000..ac7872ea93 --- /dev/null +++ b/.github/workflows/changeset.yml @@ -0,0 +1,19 @@ +name: Changeset + +on: + push: + branches: + - master + +jobs: + create-release-pr: + name: Create Changeset PR + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install Dependencies + run: yarn --frozen-lockfile + - name: Create Release Pull Request + uses: changesets/action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/chromatic-storybook-test.yml b/.github/workflows/chromatic-storybook-test.yml index 0bcaf400d0..bb14e1c38b 100644 --- a/.github/workflows/chromatic-storybook-test.yml +++ b/.github/workflows/chromatic-storybook-test.yml @@ -26,7 +26,7 @@ jobs: uses: actions/cache@v2 with: path: '**/node_modules' - key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} - name: find location of global yarn cache id: yarn-cache if: steps.cache-modules.outputs.cache-hit != 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8c92c57ff..65e092676d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true @@ -37,7 +37,7 @@ jobs: path: '**/node_modules' # We use both yarn.lock and package.json as cache keys to ensure that # changes to local monorepo packages bust the cache. - key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} # If we get a cache hit for node_modules, there's no need to bring in the global # yarn cache or run yarn install, as all dependencies will be installed already. diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000000..2fafd3756d --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: 'CodeQL' + +on: + push: + branches: [master] + pull_request: + # The branches below must be a subset of the branches above + branches: [master] + schedule: + - cron: '0 8 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + language: ['javascript'] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/docs-quality-checker.yml b/.github/workflows/docs-quality-checker.yml new file mode 100644 index 0000000000..45cf1dafe3 --- /dev/null +++ b/.github/workflows/docs-quality-checker.yml @@ -0,0 +1,18 @@ +name: Check Markdown files quality + +on: + pull_request: + branches: [master] + paths: + - '**.md' + +jobs: + check-all-files: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: documentation quality check + uses: errata-ai/vale-action@v1.3.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/e2e-win.yml b/.github/workflows/e2e-win.yml index 5bab1b1299..c48d779962 100644 --- a/.github/workflows/e2e-win.yml +++ b/.github/workflows/e2e-win.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: os: [windows-latest] - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true @@ -31,6 +31,8 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + - name: Add msbuild to PATH + uses: microsoft/setup-msbuild@v1.0.1 - name: yarn install run: yarn install --frozen-lockfile diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 69bd8393c8..e280bf59a3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -26,7 +26,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true @@ -47,7 +47,7 @@ jobs: uses: actions/cache@v2 with: path: '**/node_modules' - key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} - name: find location of global yarn cache id: yarn-cache if: steps.cache-modules.outputs.cache-hit != 'true' diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index 3f65cf9aec..c103583052 100644 --- a/.github/workflows/master-win.yml +++ b/.github/workflows/master-win.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index f99c266241..f5029bf836 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true @@ -30,7 +30,7 @@ jobs: uses: actions/cache@v2 with: path: '**/node_modules' - key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} - name: find location of global yarn cache id: yarn-cache if: steps.cache-modules.outputs.cache-hit != 'true' @@ -63,15 +63,20 @@ jobs: run: | yarn lerna -- run test -- --coverage bash <(curl -s https://codecov.io/bash) + # Upload code coverage for some specific flags. Also see .codecov.yml + bash <(curl -s https://codecov.io/bash) -f packages/core/coverage/* -F core + bash <(curl -s https://codecov.io/bash) -f packages/core-api/coverage/* -F core-api # Publishes current version of packages that are not already present in the registry - name: publish + if: matrix.node-version == '12.x' run: yarn lerna -- publish from-package --yes env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # Tags the commit with the version in the core package if the tag doesn't exist - uses: Klemensas/action-autotag@1.2.3 + if: matrix.node-version == '12.x' with: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' package_root: 'packages/core' diff --git a/.github/workflows/microsite-build-check.yml b/.github/workflows/microsite-build-check.yml index ff290b71e4..8a2fa99ed2 100644 --- a/.github/workflows/microsite-build-check.yml +++ b/.github/workflows/microsite-build-check.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [12.x, 14.x] env: CI: true @@ -33,6 +33,10 @@ jobs: run: yarn install --frozen-lockfile working-directory: microsite + - name: prettier + run: yarn prettier:check + working-directory: microsite + - name: build microsite run: yarn build working-directory: microsite diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000000..335bd80109 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,77 @@ +name: Nightly Snapshot Release + +on: + schedule: + - cron: '0 2 * * *' # run at 2 AM UTC + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x] + + env: + CI: true + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - uses: actions/checkout@v2 + + # Beginning of yarn setup, keep in sync between all workflows, see ci.yml + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + registry-url: https://registry.npmjs.org/ # Needed for auth + - name: cache all node_modules + id: cache-modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-v${{ matrix.node-version }}-node_modules-${{ hashFiles('yarn.lock', '**/package.json') }} + - name: find location of global yarn cache + id: yarn-cache + if: steps.cache-modules.outputs.cache-hit != 'true' + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + if: steps.cache-modules.outputs.cache-hit != 'true' + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: yarn install + run: yarn install --frozen-lockfile + # End of yarn setup + + # No verification done here, only build & publish. If the master branch + # is broken we will see that from those builds, but we still want to push nightly + # builds since upgrading to them is a manual process anyway. + + - name: tsc + run: yarn tsc + - name: build + run: yarn build + + # Prepares a nightly release version of any package with pending changesets + - name: prepare nightly release + run: yarn changeset version --snapshot nightly + + # Publishes the nightly release to NPM, by using tag we make sure the release is + # not flagged as the latest release, which means that people will not get this + # version of the package unless requested explicitly + - name: publish nightly release + run: yarn changeset publish --tag nightly + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Discord notification + if: ${{ failure() }} + uses: Ilshidur/action-discord@0.2.0 + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + with: + args: 'Nightly build failed https://github.com/{{GITHUB_REPOSITORY}}/actions/runs/{{GITHUB_RUN_ID}}' diff --git a/.github/workflows/scaffolder.yml b/.github/workflows/scaffolder.yml deleted file mode 100644 index bbfd52009d..0000000000 --- a/.github/workflows/scaffolder.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Scaffolder - -on: - pull_request: - paths: - - '.github/workflows/scaffolder.yml' - - './plugins/scaffolder-backend/scripts' - push: - branches: [master] -jobs: - build: - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ubuntu-latest] - python-version: [3.7] - - name: Build Container - steps: - - uses: actions/checkout@v2 - # Build Docker Image - - name: Build and push Docker images - uses: docker/build-push-action@v1.1.0 - with: - path: plugins/scaffolder-backend/scripts - dockerfile: plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile - registry: docker.pkg.github.com - repository: ${{ github.repository }}/cookiecutter - username: ${{ github.actor }} - password: ${{ github.token }} - tag_with_ref: true - push: true diff --git a/.imgbotconfig b/.imgbotconfig new file mode 100644 index 0000000000..f337e087dc --- /dev/null +++ b/.imgbotconfig @@ -0,0 +1,5 @@ +{ + "ignoredFiles": [ + "docs/assets/**/*.svg" + ] +} diff --git a/.prettierignore b/.prettierignore index 9e75b74eee..4b1acbb594 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,6 @@ .yarn dist -microsite/build +microsite coverage *.hbs templates diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000000..fd1ea53b0d --- /dev/null +++ b/.vale.ini @@ -0,0 +1,4 @@ +StylesPath = .github/styles + +[*.md] +BasedOnStyles = Vale diff --git a/CHANGELOG.md b/CHANGELOG.md index cf68fad6bd..1860a4de6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,36 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re > Collect changes for the next release below +## 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) + +### @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 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 + +- The proxy backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. + +### @backstage/techdocs-backend + +- The TechDocs backend now requires an implementation of `PluginEndpointDiscovery` from `@backstage/backend-common` to be passed in as `discovery`. See the changes to `@backstage/backend`. + +### @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) + +## 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). + ## v0.1.1-alpha.22 ### @backstage/core diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d6a8db50c..85834076d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,10 +28,14 @@ What kind of plugins should/could be created? Some inspiration from the 120+ plu ## Suggesting a plugin -If you start developing a plugin that you aim to release as open source, we suggest that you create a new [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/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. 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. + ## Write Documentation The current documentation is very limited. Help us make the `/docs` folder come alive. @@ -72,6 +76,23 @@ If you're contributing to the backend or CLI tooling, be mindful of cross-platfo 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. +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. + +# Creating Changesets + +We use [changesets](https://github.com/atlassian/changesets) to help us prepare releases. It helps us make sure that every package affected by a change gets a proper version number and an entry in its `CHANGELOG.md`. To make the process of generating releases easy. it helps when contributors include changesets with their pull requests. + +## To create a changeset + +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 +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 + +For more information, checkout [adding a changeset](https://github.com/atlassian/changesets/blob/master/docs/adding-a-changeset.md) documentation in changesets repository. + # Code of Conduct This project adheres to the [Spotify FOSS Code of Conduct][code-of-conduct]. By participating, you are expected to honor this code. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index be77f0d4de..1aebbeb0eb 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -4,7 +4,7 @@ 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 loging into Heroku's [container registry](https://devcenter.heroku.com/articles/container-registry-and-runtime). +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 diff --git a/README.md b/README.md index 9ef09aee06..e05c1eb16e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Out of the box, Backstage includes: 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). + ## Project roadmap A detailed project roadmap, including already delivered milestones, is available [here](https://backstage.io/docs/overview/roadmap). @@ -51,11 +53,9 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how - [Code of Conduct](CODE_OF_CONDUCT.md) - This is how we roll - [Adopters](ADOPTERS.md) - Companies already using Backstage - [Blog](https://backstage.io/blog/) - Announcements and updates -- [Newsletter](https://mailchi.mp/spotify/backstage-community) +- [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 â¤ī¸ -Or, if you are an open source developer and are interested in joining our team, please reach out to [foss-opportunities@spotify.com ](mailto:foss-opportunities@spotify.com) - ## License Copyright 2020 Spotify AB. diff --git a/app-config.development.yaml b/app-config.development.yaml index da274ba1a8..817847c6d6 100644 --- a/app-config.development.yaml +++ b/app-config.development.yaml @@ -9,3 +9,5 @@ backend: 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 60ecb813f7..d670771a31 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -9,32 +9,37 @@ backend: database: client: sqlite3 connection: ':memory:' + csp: + connect-src: ["'self'", 'https:'] # See README.md in the proxy-backend plugin for information on the configuration format proxy: '/circleci/api': target: https://circleci.com/api/v1.1 - changeOrigin: true - pathRewrite: - '^/proxy/circleci/api/': '/' headers: Circle-Token: - $secret: - env: CIRCLECI_AUTH_TOKEN + $env: CIRCLECI_AUTH_TOKEN '/jenkins/api': target: http://localhost:8080 headers: Authorization: - $secret: - env: JENKINS_BASIC_AUTH_HEADER + $env: JENKINS_BASIC_AUTH_HEADER + + '/travisci/api': + target: https://api.travis-ci.com + changeOrigin: true + headers: + Authorization: + $env: TRAVISCI_AUTH_TOKEN + travis-api-version: 3 organization: name: Spotify techdocs: - storageUrl: http://localhost:7000/techdocs/static/docs - requestUrl: http://localhost:7000/techdocs/docs + storageUrl: http://localhost:7000/api/techdocs/static/docs + requestUrl: http://localhost:7000/api/techdocs generators: techdocs: 'docker' @@ -44,8 +49,7 @@ sentry: rollbar: organization: spotify accountToken: - $secret: - env: ROLLBAR_ACCOUNT_TOKEN + $env: ROLLBAR_ACCOUNT_TOKEN newrelic: api: @@ -55,157 +59,194 @@ newrelic: lighthouse: baseUrl: http://localhost:3003 +kubernetes: + clusterLocatorMethod: 'configMultiTenant' + clusters: [] + +integrations: + github: + - host: github.com + token: + $env: GITHUB_PRIVATE_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 + ### 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 + gitlab: + - host: gitlab.com + token: + $env: GITLAB_PRIVATE_TOKEN + bitbucket: + - host: bitbucket.org + username: + $env: BITBUCKET_USERNAME + appPassword: + $env: BITBUCKET_APP_PASSWORD + azure: + - host: dev.azure.com + token: + $env: AZURE_PRIVATE_TOKEN + catalog: rules: - - allow: [Component, API, Group, Template, Location] + - allow: [Component, API, Group, User, Template, Location] + processors: - github: + githubOrg: providers: - target: https://github.com token: - $secret: - env: GITHUB_PRIVATE_TOKEN + $env: GITHUB_PRIVATE_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: - # $secret: - # env: GHE_PRIVATE_TOKEN - #### Example for how to add your GitHub Enterprise instance using raw HTTP fetches (token is optional): - # - target: https://ghe.example.net - # rawBaseUrl: https://ghe.example.net/raw - # token: - # $secret: - # env: GHE_PRIVATE_TOKEN - bitbucketApi: - username: - $secret: - env: BITBUCKET_USERNAME - appPassword: - $secret: - env: BITBUCKET_APP_PASSWORD - gitlabApi: - privateToken: - $secret: - env: GITLAB_PRIVATE_TOKEN - azureApi: - privateToken: - $secret: - env: AZURE_PRIVATE_TOKEN + # $env: GHE_PRIVATE_TOKEN + ldapOrg: + ### Example for how to add your enterprise LDAP server + # providers: + # - target: ldaps://ds.example.net + # bind: + # dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net + # secret: { $secret: { env: LDAP_SECRET } } + # users: + # dn: ou=people,ou=example,dc=example,dc=net + # options: + # filter: (uid=*) + # map: + # description: l + # groups: + # dn: ou=access,ou=groups,ou=example,dc=example,dc=net + # options: + # filter: (&(objectClass=some-group-class)(!(groupType=email))) locations: # Backstage example components - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-components.yaml - # Example component for github-actions - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/github-actions/examples/sample.yaml - # Example component for techdocs - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/techdocs-backend/examples/documented-component/documented-component.yaml - # Backstage example APIs - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml - # Backstage example templates - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/all-templates.yaml scaffolder: github: token: - $secret: - env: GITHUB_ACCESS_TOKEN + $env: GITHUB_ACCESS_TOKEN visibility: public # or 'internal' or 'private' gitlab: api: baseUrl: https://gitlab.com token: - $secret: - env: GITLAB_ACCESS_TOKEN + $env: GITLAB_ACCESS_TOKEN + azure: + baseUrl: https://dev.azure.com/{your-organization} + api: + token: + $env: AZURE_PRIVATE_TOKEN auth: providers: google: development: clientId: - $secret: - env: AUTH_GOOGLE_CLIENT_ID + $env: AUTH_GOOGLE_CLIENT_ID clientSecret: - $secret: - env: AUTH_GOOGLE_CLIENT_SECRET + $env: AUTH_GOOGLE_CLIENT_SECRET github: development: clientId: - $secret: - env: AUTH_GITHUB_CLIENT_ID + $env: AUTH_GITHUB_CLIENT_ID clientSecret: - $secret: - env: AUTH_GITHUB_CLIENT_SECRET + $env: AUTH_GITHUB_CLIENT_SECRET enterpriseInstanceUrl: - $secret: - env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + $env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL gitlab: development: clientId: - $secret: - env: AUTH_GITLAB_CLIENT_ID + $env: AUTH_GITLAB_CLIENT_ID clientSecret: - $secret: - env: AUTH_GITLAB_CLIENT_SECRET + $env: AUTH_GITLAB_CLIENT_SECRET audience: - $secret: - env: GITLAB_BASE_URL + $env: GITLAB_BASE_URL saml: entryPoint: 'http://localhost:7001/' issuer: 'passport-saml' okta: development: clientId: - $secret: - env: AUTH_OKTA_CLIENT_ID + $env: AUTH_OKTA_CLIENT_ID clientSecret: - $secret: - env: AUTH_OKTA_CLIENT_SECRET + $env: AUTH_OKTA_CLIENT_SECRET audience: - $secret: - env: AUTH_OKTA_AUDIENCE + $env: AUTH_OKTA_AUDIENCE oauth2: development: clientId: - $secret: - env: AUTH_OAUTH2_CLIENT_ID + $env: AUTH_OAUTH2_CLIENT_ID clientSecret: - $secret: - env: AUTH_OAUTH2_CLIENT_SECRET + $env: AUTH_OAUTH2_CLIENT_SECRET authorizationUrl: - $secret: - env: AUTH_OAUTH2_AUTH_URL + $env: AUTH_OAUTH2_AUTH_URL tokenUrl: - $secret: - env: AUTH_OAUTH2_TOKEN_URL + $env: AUTH_OAUTH2_TOKEN_URL auth0: development: clientId: - $secret: - env: AUTH_AUTH0_CLIENT_ID + $env: AUTH_AUTH0_CLIENT_ID clientSecret: - $secret: - env: AUTH_AUTH0_CLIENT_SECRET + $env: AUTH_AUTH0_CLIENT_SECRET domain: - $secret: - env: AUTH_AUTH0_DOMAIN + $env: AUTH_AUTH0_DOMAIN microsoft: development: clientId: - $secret: - env: AUTH_MICROSOFT_CLIENT_ID + $env: AUTH_MICROSOFT_CLIENT_ID clientSecret: - $secret: - env: AUTH_MICROSOFT_CLIENT_SECRET + $env: AUTH_MICROSOFT_CLIENT_SECRET tenantId: - $secret: - env: AUTH_MICROSOFT_TENANT_ID + $env: AUTH_MICROSOFT_TENANT_ID +costInsights: + engineerCost: 200000 + products: + computeEngine: + name: Compute Engine + icon: compute + cloudDataflow: + name: Cloud Dataflow + icon: data + cloudStorage: + name: Cloud Storage + icon: storage + bigQuery: + name: Big Query + icon: search + metrics: + dailyCost: + name: Your Company's Daily Cost + DAU: + name: Cost Per DAU +homepage: + clocks: + - label: UTC + timzone: UTC + - label: NYC + timezone: 'America/New_York' + - label: STO + timezone: 'Europe/Stockholm' + - label: TYO + timezone: 'Asia/Tokyo' diff --git a/backstage_overview.png b/backstage_overview.png deleted file mode 100644 index 6c939c127c..0000000000 Binary files a/backstage_overview.png and /dev/null differ diff --git a/contrib/docker/multi-stage-frontend/Dockerfile b/contrib/docker/multi-stage-frontend/Dockerfile index 0c492478d1..3190687c52 100644 --- a/contrib/docker/multi-stage-frontend/Dockerfile +++ b/contrib/docker/multi-stage-frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12 AS build +FROM node:12-buster AS build RUN mkdir /app COPY . /app diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md new file mode 100644 index 0000000000..855608c8b1 --- /dev/null +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md @@ -0,0 +1,58 @@ +### Source repo: https://github.com/johnson-jesse/simple-backstage-app-plugin + +ExampleComponent.tsx reference + +```tsx +import React, { FC } from 'react'; +import { Typography, Grid } from '@material-ui/core'; +import { + InfoCard, + Header, + Page, + pageTheme, + Content, + ContentHeader, + HeaderLabel, + SupportButton, + identityApiRef, +} from '@backstage/core'; +import { useApi } from '@backstage/core-api'; +import ExampleFetchComponent from '../ExampleFetchComponent'; + +const ExampleComponent: FC<{}> = () => { + const identityApi = useApi(identityApiRef); + const userId = identityApi.getUserId(); + const profile = identityApi.getProfile(); + + return ( + +
+ + +
+ + + A description of your plugin goes here. + + + + + + {`${profile.displayName} | ${profile.email}`} + + + + + + + + +
+ ); +}; + +export default ExampleComponent; +``` diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md new file mode 100644 index 0000000000..08d14e8c4c --- /dev/null +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md @@ -0,0 +1,111 @@ +### Source repo: https://github.com/johnson-jesse/simple-backstage-app-plugin + +ExampleFetchComponent.tsx reference + +```tsx +import React, { FC } from 'react'; +import { useAsync } from 'react-use'; +import Alert from '@material-ui/lab/Alert'; +import { + Table, + TableColumn, + Progress, + githubAuthApiRef, +} from '@backstage/core'; +import { useApi } from '@backstage/core-api'; +import { graphql } from '@octokit/graphql'; + +const query = `{ + viewer { + repositories(first: 100) { + totalCount + nodes { + name + createdAt + description + diskUsage + isFork + } + pageInfo { + endCursor + hasNextPage + } + } + } +}`; + +type Node = { + name: string; + createdAt: string; + description: string; + diskUsage: number; + isFork: boolean; +}; + +type Viewer = { + repositories: { + totalCount: number; + nodes: Node[]; + pageInfo: { + endCursor: string; + hasNextPage: boolean; + }; + }; +}; + +type DenseTableProps = { + viewer: Viewer; +}; + +export const DenseTable: FC = ({ viewer }) => { + const columns: TableColumn[] = [ + { title: 'Name', field: 'name' }, + { title: 'Created', field: 'createdAt' }, + { title: 'Description', field: 'description' }, + { title: 'Disk Usage', field: 'diskUsage' }, + { title: 'Fork', field: 'isFork' }, + ]; + + return ( + + ); +}; + +const ExampleFetchComponent: FC<{}> = () => { + const auth = useApi(githubAuthApiRef); + + const { value, loading, error } = useAsync(async (): Promise => { + const token = await auth.getAccessToken(); + + const gqlEndpoint = graphql.defaults({ + // Uncomment baseUrl if using enterprise + // baseUrl: 'https://github.MY-BIZ.com/api', + headers: { + authorization: `token ${token}`, + }, + }); + const { viewer } = await gqlEndpoint(query); + return viewer; + }, []); + + if (loading) return ; + if (error) return {error.message}; + if (value && value.repositories) return ; + + return ( +
+ ); +}; + +export default ExampleFetchComponent; +``` diff --git a/docs/FAQ.md b/docs/FAQ.md index b4c4970ff8..355cf97880 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -86,7 +86,7 @@ well-known tech and a large flora of components. ### What technology does Backstage use? -The code base is a large-scale React application that uses TypeScript. For +The codebase is a large-scale React application that uses TypeScript. For [Phase 2](https://github.com/spotify/backstage#project-roadmap), we plan to use Node.js and GraphQL. @@ -132,20 +132,19 @@ to see if it already exists or is in the works. If no one's thought of it yet, great! Open a new issue as [a plugin suggestion](https://github.com/spotify/backstage/issues/new/choose) and describe what your plugin will do. This will help coordinate our -contributors' efforts and avoid duplicating existing functionality. In the -future, we will create -[a plugin gallery](https://github.com/spotify/backstage/issues/260) where people -can browse and search for all available plugins. +contributors' efforts and avoid duplicating existing functionality. + +You can browse and search for all available plugins in the +[Plugin Marketplace](https://backstage.io/plugins). ### Which plugin is used the most at Spotify? By far, our most-used plugin is our TechDocs plugin, which we use for creating technical documentation. Our philosophy at Spotify is to treat "docs like code", where you write documentation using the same workflow as you write your code. -This makes it easier to create, find, and update documentation. We hope to -release -[the open source version](https://github.com/spotify/backstage/issues/687) in -the future. (See also: +This makes it easier to create, find, and update documentation. +[TechDocs is now open source.](https://backstage.io/docs/features/techdocs/techdocs-overview) +(See also: "[Will Spotify's internal plugins be open sourced, too?](#will-spotifys-internal-plugins-be-open-sourced-too)" above) @@ -205,7 +204,7 @@ Please report sensitive security issues via Spotify's ### Does Backstage collect any information that is shared with Spotify? No. Backstage does not collect any telemetry from any third party using the -platform. Spotify, and the open source community, does have access to +platform. Spotify, and the open source community, do have access to [GitHub Insights](https://github.com/features/insights), which contains information such as contributors, commits, traffic, and dependencies. Backstage is an open platform, but you are in control of your own data. You control who diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index d192cbd6ee..1ebf2ae88b 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -97,7 +97,7 @@ app, and the app itself. ### Core APIs -Starting with the Backstage core library, it provides implementation for all of +Starting with the Backstage core library, it provides implementations for all of the core APIs. The core APIs are the ones exported by `@backstage/core`, such as the `errorApiRef` and `configApiRef`. You can find a full list of them [here](../reference/utility-apis/README.md). @@ -113,7 +113,7 @@ While doing so they should usually also provide default implementations of their own APIs, for example, the `catalog` plugin exports `catalogApiRef`, and also supplies a default `ApiFactory` of that API using the `CatalogClient`. There is one restriction to plugin-provided API Factories: plugins may not supply -factories for core APIs, trying to do so will cause the app to crash. +factories for core APIs, trying to do so will cause the app to refuse to start. Plugins supply their APIs through the `apis` option of `createPlugin`, for example: @@ -248,8 +248,8 @@ directly tied to React. The indirection provided by Utility APIs also makes it straightforward to test components that depend on APIs, and to provide a standard common development environment for plugins. A proper test wrapper with mocked API implementations -is not yet ready, but it will provided as a part of `@backstage/test-utils`. It -will provide mocked variants of APIs, with additional methods for asserting a +is not yet ready, but it will be provided as a part of `@backstage/test-utils`. +It will provide mocked variants of APIs, with additional methods for asserting a component's interaction with the API. The common development environment for plugins is included in diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index f4fd3a1158..d831581d5b 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -22,7 +22,7 @@ This ADR describes the default format of these descriptor files. ### Inspiration -Internally at Spotify, a home grown software catalog system is used heavily and +Internally at Spotify, a homegrown software catalog system is used heavily and forms a core part of Backstage and other important pieces of the infrastructure. The user experience, learnings and certain pieces of metadata from that catalog are being carried over to the open source effort. @@ -40,7 +40,7 @@ triggers from the version control system, etc. Each file describes one or more entities in accordance with the [Backstage System Model](https://github.com/spotify/backstage/issues/390). All -of these entities have a common stucture and nomenclature, and they are stored +of these entities have a common structure and nomenclature, and they are stored in the software catalog from which they then can be queried. Entities have distinct names, and they may reference each other by those names. diff --git a/docs/architecture-decisions/adr003-avoid-default-exports.md b/docs/architecture-decisions/adr003-avoid-default-exports.md index 8634a19632..d08bc6a882 100644 --- a/docs/architecture-decisions/adr003-avoid-default-exports.md +++ b/docs/architecture-decisions/adr003-avoid-default-exports.md @@ -44,7 +44,7 @@ A summary: name each. Using named exports helps prevent needing to rename symbols, which has myriad -benefts. A few are: +benefits. A few are: - IDE tools like "Find All References" and "Go To Definition" function - Manual codebase searching ("grep", etc) is easier with a unique symbol diff --git a/docs/architecture-decisions/adr004-module-export-structure.md b/docs/architecture-decisions/adr004-module-export-structure.md index 12408abac1..14c94c4ed1 100644 --- a/docs/architecture-decisions/adr004-module-export-structure.md +++ b/docs/architecture-decisions/adr004-module-export-structure.md @@ -22,8 +22,8 @@ or We currently do not use any pattern for how to structure exports. There is a mix of package-level re-exports deep into the directory tree, shallow re-exports for each directory, exports using `*` and explicit lists of each symbol, etc. The -mix and lack of predictability makes it difficult to reason about the boundaries -of a module, and for example knowing whether is is safe to export a symbol in a +mix and lack of predictability make it difficult to reason about the boundaries +of a module, and for example knowing whether it is safe to export a symbol in a given file. ## Decision diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md index bea36712d7..96f594daf7 100644 --- a/docs/architecture-decisions/adr006-avoid-react-fc.md +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -13,7 +13,7 @@ with next to no benefits in combination with a few downsides. The main reasons were: - **children props** were implicitly added -- **Generic Type** were not supported on children +- **Generic Type** was not supported on children Read more about the removal in [this PR](https://github.com/facebook/create-react-app/pull/8177). @@ -25,7 +25,7 @@ should be avoided in our codebase when adding new code. Here is an example: -```ts +```typescript /* Avoid this: */ type BadProps = { text: string }; const BadComponent: FC = ({ text, children }) => ( diff --git a/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md index 6b18f67d40..045588f505 100644 --- a/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md +++ b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md @@ -11,7 +11,7 @@ tests, unit tests to e2e tests always have their own implementation of mocking these requests. There's been traction in the outer community towards using this library to mock network requests by using an express style declaration for routes. react-testing-library suggests using this library instead of mocking -fetch directly wether this be in a browser or in node. +fetch directly whether this be in a browser or in node. https://github.com/mswjs/msw diff --git a/docs/architecture-decisions/adr008-default-catalog-file-name.md b/docs/architecture-decisions/adr008-default-catalog-file-name.md index c57d10c0b1..d4716318bc 100644 --- a/docs/architecture-decisions/adr008-default-catalog-file-name.md +++ b/docs/architecture-decisions/adr008-default-catalog-file-name.md @@ -7,7 +7,7 @@ description: Architecture Decision Record (ADR) log on Default Catalog File Name ## Background While the spec for the catalog file format is well described in -[ADR002](./adr002-default-catalog-file-format.md), guidance was note provided as +[ADR002](./adr002-default-catalog-file-format.md), guidance was not provided as to the name of the catalog file. Following discussion in @@ -23,4 +23,4 @@ catalog-info.yaml ``` This name is a default, **not a requirement**. The catalog file will work with -Backstage irregardless of its name. +Backstage regardless of its name. diff --git a/docs/architecture-decisions/adr009-entity-references.md b/docs/architecture-decisions/adr009-entity-references.md new file mode 100644 index 0000000000..8b7984ea49 --- /dev/null +++ b/docs/architecture-decisions/adr009-entity-references.md @@ -0,0 +1,69 @@ +--- +id: adrs-adr009 +title: ADR009: Entity References +description: Architecture Decision Record (ADR) log on Entity References +--- + +## Background + +While the spec for the catalog file format is well described in +[ADR002](./adr002-default-catalog-file-format.md), guidance was not provided as +to how one is expected to express references to other entities in the catalog. +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 +made. + +## Entity References in YAML files + +The textual format, as written by humans, to reference entities by name is on +the following form, where square brackets denote optionality: + +``` +[:][/] +``` + +That is, it is composed of between one and three parts in this specific order, +without any additional encoding, with those exact separator characters. +Optionality of `kind` and `namespace` are contextual, and they may or may not +have default contextual fallback values. + +When that format is insufficient or when machine made interchange formats wish +to express such relations in a more expressive form, a nested structure on the +following form can be used: + +```yaml +kind: +namespace: +name: +``` + +Of these, only `name` is always required. Optionality of `kind` and `namespace` +are contextual, and they may or may not have default contextual fallback values. +All other possible key values in this structure are reserved for future use. + +A system or user wanting to express a full entity name that is always valid, +shall supply the entire triplet whether using the string form or the compound +form. + +A full description of the format can be found +[in the documentation](https://backstage.io/docs/features/software-catalog/references). + +## Entity References in URLs + +Where entities are referenced by name in the Backstage frontend, the URL +containing the reference shall take the following form: + +``` +:namespace/:kind/:name +``` + +All three parts are required under all circumstances. The default value for the +`namespace` in the catalog is the string `"default"`, if the entity does not +specify one explicitly in `metadata.namespace`. + +This means that we do not encourage the string form of entity references to be +used as a single URL segment, due to the use of URL-unsafe characters leading to +possible risk, confusion, and uglier URLs. diff --git a/docs/assets/architecture-decisions/catalog-core-entities.png b/docs/assets/architecture-decisions/catalog-core-entities.png index b0c7cb4575..b7f238708f 100644 Binary files a/docs/assets/architecture-decisions/catalog-core-entities.png and b/docs/assets/architecture-decisions/catalog-core-entities.png differ diff --git a/docs/assets/architecture-overview/circle-ci.png b/docs/assets/architecture-overview/circle-ci.png index c5695196f1..48aebc9200 100644 Binary files a/docs/assets/architecture-overview/circle-ci.png and b/docs/assets/architecture-overview/circle-ci.png differ diff --git a/docs/assets/architecture-overview/core-vs-plugin-components-highlighted.png b/docs/assets/architecture-overview/core-vs-plugin-components-highlighted.png index 7d08fc5e36..f0a8e8871d 100644 Binary files a/docs/assets/architecture-overview/core-vs-plugin-components-highlighted.png and b/docs/assets/architecture-overview/core-vs-plugin-components-highlighted.png differ diff --git a/docs/assets/architecture-overview/lighthouse-plugin-architecture.png b/docs/assets/architecture-overview/lighthouse-plugin-architecture.png index 0da5d6f042..0d67bcf19b 100644 Binary files a/docs/assets/architecture-overview/lighthouse-plugin-architecture.png and b/docs/assets/architecture-overview/lighthouse-plugin-architecture.png differ diff --git a/docs/assets/architecture-overview/lighthouse-plugin.png b/docs/assets/architecture-overview/lighthouse-plugin.png index 9692fc0833..0b9d0ccc8a 100644 Binary files a/docs/assets/architecture-overview/lighthouse-plugin.png and b/docs/assets/architecture-overview/lighthouse-plugin.png differ diff --git a/docs/assets/architecture-overview/tech-radar-plugin-architecture.png b/docs/assets/architecture-overview/tech-radar-plugin-architecture.png index d55be5ee82..417a2764a1 100644 Binary files a/docs/assets/architecture-overview/tech-radar-plugin-architecture.png and b/docs/assets/architecture-overview/tech-radar-plugin-architecture.png differ diff --git a/docs/assets/architecture-overview/tech-radar-plugin.png b/docs/assets/architecture-overview/tech-radar-plugin.png index dbf39b6c63..062227ac80 100644 Binary files a/docs/assets/architecture-overview/tech-radar-plugin.png and b/docs/assets/architecture-overview/tech-radar-plugin.png differ diff --git a/docs/assets/auth/oauth-popup-flow.svg b/docs/assets/auth/oauth-popup-flow.svg index 2132903783..4a6e76ed21 100644 --- a/docs/assets/auth/oauth-popup-flow.svg +++ b/docs/assets/auth/oauth-popup-flow.svg @@ -1,50 +1,50 @@ OAuth Consent and Refresh FlowBrowserBrowserPopup WindowPopup Windowauth-backend pluginauth-backend pluginConsent ScreenConsent ScreenOAuth ProviderOAuth ProviderComponents on page ask for anaccess token with greaterscope than the existing session.Open popupGET /auth/<provider>/start?scope=some%20scopesRedirect to consent screen withrandom nonce in OAuth state andshort-lived cookie with the same nonce.GET /consent_url?redirect_uri=<redirect_uri>?nonce=<n>where redirect_uri=<app-origin>/auth/<provider>/handler/frameUser consents toaccess the new scope.Redirect to given redirect URL, with authorization codeGET /auth/<provider>/handler/frame?code=<c>&nonce=<n>Request includes the previously set none cookieVerify that the nonce in the cookiematches the nonce in the OAuth stateAuthorization CodeClient IDClient SecretVerify and generate tokensAccess Token(ID Token)(Refresh Token)ScopeExpire TimeSmall HTML page with inlined response payloadStore Refresh Token in HTTP-only cookiepostMessage() with tokens and info or errorClose selfA later point when a refreshis needed. Either because ofa reload or an expiring session.GET /auth/<provider>/tokenRefresh Token cookie includedRefresh TokenClient IDClient SecretAccess Token(ID Token)ScopeExpire TimeTokens and info \ No newline at end of file +--> diff --git a/docs/assets/contributorheader.png b/docs/assets/contributorheader.png index 53d1027d85..64d0cb4cff 100644 Binary files a/docs/assets/contributorheader.png and b/docs/assets/contributorheader.png differ diff --git a/docs/assets/dls/DLS.png b/docs/assets/dls/DLS.png index 94214e4650..f84b94857d 100644 Binary files a/docs/assets/dls/DLS.png and b/docs/assets/dls/DLS.png differ diff --git a/docs/assets/dls/designheader-updated.png b/docs/assets/dls/designheader-updated.png index 56c5a56abb..39aded2252 100644 Binary files a/docs/assets/dls/designheader-updated.png and b/docs/assets/dls/designheader-updated.png differ diff --git a/docs/assets/dls/designheader.png b/docs/assets/dls/designheader.png index e9ace5c2e7..8f160039df 100644 Binary files a/docs/assets/dls/designheader.png and b/docs/assets/dls/designheader.png differ diff --git a/docs/assets/dls/running-storybook.png b/docs/assets/dls/running-storybook.png index 6cf1ded4b9..ce9422b99d 100644 Binary files a/docs/assets/dls/running-storybook.png and b/docs/assets/dls/running-storybook.png differ diff --git a/docs/assets/dls/storybook-page.png b/docs/assets/dls/storybook-page.png index 113f96e589..58470feaff 100644 Binary files a/docs/assets/dls/storybook-page.png and b/docs/assets/dls/storybook-page.png differ diff --git a/docs/assets/getting-started/create-app_output.png b/docs/assets/getting-started/create-app_output.png index caa39ec6d2..875ad05122 100644 Binary files a/docs/assets/getting-started/create-app_output.png and b/docs/assets/getting-started/create-app_output.png differ diff --git a/docs/assets/getting-started/create-plugin_output.png b/docs/assets/getting-started/create-plugin_output.png index f048a9f5fc..47231c2079 100644 Binary files a/docs/assets/getting-started/create-plugin_output.png and b/docs/assets/getting-started/create-plugin_output.png differ diff --git a/docs/assets/headline.png b/docs/assets/headline.png index 83d7b14f21..fef99a269f 100644 Binary files a/docs/assets/headline.png and b/docs/assets/headline.png differ diff --git a/docs/assets/my-plugin_screenshot.png b/docs/assets/my-plugin_screenshot.png index 0b2817fa1e..4f2849a691 100644 Binary files a/docs/assets/my-plugin_screenshot.png and b/docs/assets/my-plugin_screenshot.png differ diff --git a/docs/assets/pop.png b/docs/assets/pop.png index 441126f33c..614136b501 100644 Binary files a/docs/assets/pop.png and b/docs/assets/pop.png differ diff --git a/docs/assets/software-catalog/bsc-edit.png b/docs/assets/software-catalog/bsc-edit.png index d1ac1f52e5..f3a7daa1dd 100644 Binary files a/docs/assets/software-catalog/bsc-edit.png and b/docs/assets/software-catalog/bsc-edit.png differ diff --git a/docs/assets/software-catalog/bsc-register-1.png b/docs/assets/software-catalog/bsc-register-1.png index 117b2ea8ea..0e33c173fb 100644 Binary files a/docs/assets/software-catalog/bsc-register-1.png and b/docs/assets/software-catalog/bsc-register-1.png differ diff --git a/docs/assets/software-catalog/bsc-register-2.png b/docs/assets/software-catalog/bsc-register-2.png index fd1ea7b998..de71141ba0 100644 Binary files a/docs/assets/software-catalog/bsc-register-2.png and b/docs/assets/software-catalog/bsc-register-2.png differ diff --git a/docs/assets/software-catalog/bsc-search.png b/docs/assets/software-catalog/bsc-search.png index 8e417cb076..042e6055e0 100644 Binary files a/docs/assets/software-catalog/bsc-search.png and b/docs/assets/software-catalog/bsc-search.png differ diff --git a/docs/assets/software-catalog/bsc-starred.png b/docs/assets/software-catalog/bsc-starred.png index 27db19c842..c9721c27a2 100644 Binary files a/docs/assets/software-catalog/bsc-starred.png and b/docs/assets/software-catalog/bsc-starred.png differ diff --git a/docs/assets/software-catalog/service-catalog-home.png b/docs/assets/software-catalog/service-catalog-home.png index 742748632e..1d2e8ed317 100644 Binary files a/docs/assets/software-catalog/service-catalog-home.png and b/docs/assets/software-catalog/service-catalog-home.png differ diff --git a/docs/assets/software-catalog/software-model-core-entities.png b/docs/assets/software-catalog/software-model-core-entities.png index b718b7527c..60cb283802 100644 Binary files a/docs/assets/software-catalog/software-model-core-entities.png and b/docs/assets/software-catalog/software-model-core-entities.png differ diff --git a/docs/assets/software-templates/added-to-the-catalog-list.png b/docs/assets/software-templates/added-to-the-catalog-list.png index 4b544e51c1..c73c33ede3 100644 Binary files a/docs/assets/software-templates/added-to-the-catalog-list.png and b/docs/assets/software-templates/added-to-the-catalog-list.png differ diff --git a/docs/assets/software-templates/complete.png b/docs/assets/software-templates/complete.png index eee14fae0e..2a8de0ba1e 100644 Binary files a/docs/assets/software-templates/complete.png and b/docs/assets/software-templates/complete.png differ diff --git a/docs/assets/software-templates/create.png b/docs/assets/software-templates/create.png index 8a2e92b9f4..8123780cd3 100644 Binary files a/docs/assets/software-templates/create.png and b/docs/assets/software-templates/create.png differ diff --git a/docs/assets/software-templates/failed.png b/docs/assets/software-templates/failed.png index bca8c72d6a..4cb7ce84cc 100644 Binary files a/docs/assets/software-templates/failed.png and b/docs/assets/software-templates/failed.png differ diff --git a/docs/assets/software-templates/go-to-catalog.png b/docs/assets/software-templates/go-to-catalog.png index ae16230a02..ea03295846 100644 Binary files a/docs/assets/software-templates/go-to-catalog.png and b/docs/assets/software-templates/go-to-catalog.png differ diff --git a/docs/assets/software-templates/running.png b/docs/assets/software-templates/running.png index 208376e059..681c3702f7 100644 Binary files a/docs/assets/software-templates/running.png and b/docs/assets/software-templates/running.png differ diff --git a/docs/assets/software-templates/template-picked-2.png b/docs/assets/software-templates/template-picked-2.png index 685e356ee8..a1737c8e00 100644 Binary files a/docs/assets/software-templates/template-picked-2.png and b/docs/assets/software-templates/template-picked-2.png differ diff --git a/docs/assets/software-templates/template-picked.png b/docs/assets/software-templates/template-picked.png index 1094acec4a..79b1e260e0 100644 Binary files a/docs/assets/software-templates/template-picked.png and b/docs/assets/software-templates/template-picked.png differ diff --git a/docs/assets/techdocs/documentation-template.png b/docs/assets/techdocs/documentation-template.png index 1f44ad27c6..7539f1f6f4 100644 Binary files a/docs/assets/techdocs/documentation-template.png and b/docs/assets/techdocs/documentation-template.png differ diff --git a/docs/assets/techdocs/techdocs_big_picture.png b/docs/assets/techdocs/techdocs_big_picture.png index ffe437180f..8b8f7a2338 100644 Binary files a/docs/assets/techdocs/techdocs_big_picture.png and b/docs/assets/techdocs/techdocs_big_picture.png differ diff --git a/docs/assets/utility-apis-fig1.svg b/docs/assets/utility-apis-fig1.svg index 47123299c7..3261de88a8 100644 --- a/docs/assets/utility-apis-fig1.svg +++ b/docs/assets/utility-apis-fig1.svg @@ -1,3 +1,3 @@ -
fooApiRef
fooApiRef
FooApi
FooApi
Plugin
Component
Component
App A
App A
App B
App B
DefaultFooApi
DefaultFooApi
CustomFooApi
CustomFooApi
App C
App C
Viewer does not support full SVG 1.1
\ No newline at end of file +
fooApiRef
fooApiRef
FooApi
FooApi
Plugin
Component
Component
App A
App A
App B
App B
DefaultFooApi
DefaultFooApi
CustomFooApi
CustomFooApi
App C
App C
Viewer does not support full SVG 1.1
diff --git a/docs/auth/add-auth-provider.md b/docs/auth/add-auth-provider.md index c5f7645a49..56586cbfc6 100644 --- a/docs/auth/add-auth-provider.md +++ b/docs/auth/add-auth-provider.md @@ -229,9 +229,10 @@ name. ### Test the new provider -You can `curl -i localhost:7000/auth/providerA/start` and which should provide a -`302` redirect with a `Location` header. Paste the url from that header into a -web browser and you should be able to trigger the authorization flow. +You can `curl -i localhost:7000/api/auth/providerA/start` and which should +provide a `302` redirect with a `Location` header. Paste the url from that +header into a web browser and you should be able to trigger the authorization +flow. --- diff --git a/docs/auth/auth-backend-classes.md b/docs/auth/auth-backend-classes.md index 4ccee8ede9..9dc677b439 100644 --- a/docs/auth/auth-backend-classes.md +++ b/docs/auth/auth-backend-classes.md @@ -67,26 +67,21 @@ auth: google: development: clientId: - $secret: - env: AUTH_GOOGLE_CLIENT_ID + $env: AUTH_GOOGLE_CLIENT_ID clientSecret: - $secret: - env: AUTH_GOOGLE_CLIENT_SECRET + $env: AUTH_GOOGLE_CLIENT_SECRET github: development: clientId: - $secret: - env: AUTH_GITHUB_CLIENT_ID + $env: AUTH_GITHUB_CLIENT_ID clientSecret: - $secret: - env: AUTH_GITHUB_CLIENT_SECRET + $env: AUTH_GITHUB_CLIENT_SECRET enterpriseInstanceUrl: - $secret: - env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + $env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL gitlab: development: clientId: - $secret: + $env: ... ``` diff --git a/docs/auth/index.md b/docs/auth/index.md index 0fa8de3280..d7fb5d8c59 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -6,7 +6,7 @@ description: Documentation on User Authentication and Authorization in Backstage ## Summary -The purpose of the Auth APIs in Backstage are to identify the user, and to +The purpose of the Auth APIs in Backstage is to identify the user, and to provide a way for plugins to request access to 3rd party services on behalf of the user (OAuth). This documentation focuses on the implementation of that solution and how to extend it. For documentation on how to consume the Auth APIs @@ -35,6 +35,13 @@ full list of providers, see the ### Identity - WIP +> NOTE: Identity management and the `SignInPage` in Backstage is NOT a method +> for blocking access for unauthorized users, that either requires additional +> backend implementation or a separate service like Google's Identity-Aware +> Proxy. The identity system only serves to provide a personalized experience +> and access to a Backstage Identity Token, which can be passed to backend +> plugins. + Identity management is still work in progress, but there are already a couple of pieces in place that can be used. diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index e90b7b665a..178b99b153 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -52,7 +52,7 @@ in a new popup window that is opened by the app. By using a popup-based flow it is possible to request authentication at any point in the app, without requiring a redirect. Because of this there is no need to ask for all scopes upfront, or interrupt the app with a redirect and forcing plugin authors to take care in -restoring state after a redirect has been make. All in all it makes it much +restoring state after a redirect has been made. All in all it makes it much easier to make authenticated requests inside a plugin. ## OAuth Flow @@ -67,7 +67,7 @@ Component and APIs can request Access or ID Tokens from any available Auth provider. If there already exists a cached fresh token that covers (at least) the requested scopes, it will be returned immediately. If the OAuth provider implements token refreshes, this check will also trigger a token refresh attempt -if no session is a available. +if no session is available. If new scopes are requested, or the user is not yet logged in with that provider, a dialog is shown informing the user that they need to log in with the diff --git a/docs/conf/index.md b/docs/conf/index.md index 83eff50a7d..e1dfc30582 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -18,7 +18,8 @@ allowing for customization. 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 a `$secret` key. +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 diff --git a/docs/conf/writing.md b/docs/conf/writing.md index 6d338e586d..5564e780bb 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -90,18 +90,17 @@ order: ## Secrets -Secrets are supported via a special `$secret` key, which in turn provides a -number of different ways to read in secrets. To load a configuration value as a -secret, supply an object with a single `$secret` key, and within that supply an -object that describes how the secret is loaded. For example, the following will -read the config key `backend.mySecretKey` from the environment variable -`MY_SECRET_KEY`: +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 +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 +`backend.mySecretKey` from the environment variable `MY_SECRET_KEY`: ```yaml backend: mySecretKey: - $secret: - env: MY_SECRET_KEY + $env: MY_SECRET_KEY ``` With the above configuration, calling `config.getString('backend.mySecretKey')` @@ -123,8 +122,7 @@ This reads a secret from an environment variable. For example, the following config loads the secret from the `MY_SECRET` env var. ```yaml -$secret: - env: MY_SECRET +$env: MY_SECRET ``` ### File Secrets @@ -135,22 +133,19 @@ following reads the contents of `my-secret.txt` relative to the config file itself: ```yaml -$secret: - file: ./my-secret.txt +$file: ./my-secret.txt ``` ### Data File Secrets This reads secrets from a path within a JSON-like data file. The file path -behaves similar to file secrets, but in addition a `path` is used to point to a -specific value inside the file. Supported file extensions are `.json`, `.yaml`, -and `.yml`. For example, the following would read out `my-secret-key` from -`my-secrets.json`: +behaves similar to file secrets, but with the addition of a url fragment that is +used to point to a specific value inside the file. Supported file extensions are +`.json`, `.yaml`, and `.yml`. For example, the following would read out +`my-secret-key` from `my-secrets.json`: ```yaml -$secret: - data: ./my-secrets.json - path: deployment.key +$data: ./my-secrets.json#deployment.key # my-secrets.json { diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md index c98bd73ca3..46de2d3d67 100644 --- a/docs/features/software-catalog/configuration.md +++ b/docs/features/software-catalog/configuration.md @@ -11,36 +11,50 @@ tasks, such as reading raw entity data from a remote source, parsing it, transforming it, and validating it. These processors are configured under the `catalog.processors` key. -### Processor: github +### Processor: url -The `github` processor is responsible for fetching entity data from files on -GitHub or GitHub Enterprise. The configuration for this processor lives under -`catalog.processors.github`. Example: +The `url` processor is responsible for fetching entity data from files in any +external provider like GitHub, GitLab, Bitbucket, etc. The configuration of this +processor lives under the top-level `integrations` key, as it is used by other +parts of Backstage too. ```yaml -catalog: - processors: - github: - providers: - - target: https://github.com - token: - $secret: - env: GITHUB_PRIVATE_TOKEN - - target: https://ghe.example.net - apiBaseUrl: https://ghe.example.net/api/v3 - rawBaseUrl: https://ghe.example.net/raw - token: - $secret: - env: GHE_PRIVATE_TOKEN +integrations: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + - host: ghe.example.net + apiBaseUrl: https://ghe.example.net/api/v3 + rawBaseUrl: https://ghe.example.net/raw + token: + $env: GHE_TOKEN + gitlab: + - host: gitlab.com + token: + $env: GITLAB_TOKEN + bitbucket: + - host: bitbucket.org + username: + $env: BITBUCKET_USERNAME + appPassword: + $env: BITBUCKET_APP_PASSWORD + azure: + - host: dev.azure.com + token: + $env: AZURE_TOKEN ``` -The main subkey is `providers`, where you can list the various GitHub compatible -providers you want to be able to fetch data from. Each entry is a structure with -up to four elements: +Each key under `integrations` is a separate configuration for each external +provider. The providers each have their own configuration, so let's look at the +GitHub section as an example. -- `target` (required): The string prefix of the location target that you want to - match on, with no trailing slash. For GitHub, it should be exactly - `https://github.com`. +Directly under the `github` key is a list of provider configurations, where you +can list the various GitHub compatible providers you want to be able to fetch +data from. Each entry is a structure with up to four elements: + +- `host` (optional): The host of the location target that you want to match on. + The default host is `github.com`. - `token` (optional): An authentication token as expected by GitHub. If supplied, it will be passed along with all calls to this provider, both API and raw. If it is not supplied, anonymous access will be used. @@ -74,7 +88,7 @@ the catalog under the `catalog.locations` key, for example: ```yaml catalog: locations: - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml ``` @@ -97,7 +111,7 @@ catalog: - allow: [Component, API, Location, Template] locations: - - type: github + - type: url target: https://github.com/org/example/blob/master/org-data.yaml rules: - allow: [Group] diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 301d80630c..0c50e3669a 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -22,6 +22,8 @@ humans. However, the structure and semantics is the same in both cases. - [Kind: Component](#kind-component) - [Kind: Template](#kind-template) - [Kind: API](#kind-api) +- [Kind: Group](#kind-group) +- [Kind: User](#kind-user) ## Overall Shape Of An Entity @@ -571,3 +573,167 @@ group of people in an organizational structure. The definition of the API, based on the format defined by `spec.type`. This field is required. + +## Kind: Group + +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `Group` | + +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`](#kind-user). + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: infrastructure + description: The infra business unit +spec: + type: business-unit + parent: ops + ancestors: [ops, global-synergies, acme-corp] + children: [backstage, other] + descendants: [backstage, other, team-a, team-b, team-c, team-d] +``` + +In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) +shape, this kind has the following structure. + +### `apiVersion` and `kind` [required] + +Exactly equal to `backstage.io/v1alpha1` and `Group`, respectively. + +### `spec.type` [required] + +The type of group as a string, e.g. `team`. There is currently no enforced set +of values for this field, so it is left up to the adopting organization to +choose a nomenclature that matches their org hierarchy. + +Some common values for this field could be: + +- `team` +- `business-unit` +- `product-area` +- `root` - as a common virtual root of the hierarchy, if desired + +### `spec.parent` [optional] + +The immediate parent group in the hierarchy, if any. Not all groups must have a +parent; the catalog supports multi-root hierarchies. Groups may however not have +more than one parent. + +This field is an +[entity reference](https://backstage.io/docs/features/software-catalog/references), +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, +this field points to a group in the same namespace, so in those cases it is +sufficient to enter only the `metadata.name` field of that group. + +### `spec.ancestors` [required] + +The recursive list of parents up the hierarchy, by stepping through parents one +by one. The list must be present, but may be empty if `parent` is not present. +The first entry in the list is equal to `parent`, and then the following ones +are progressively farther up the hierarchy. + +The entries of this array are +[entity references](https://backstage.io/docs/features/software-catalog/references), +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. + +### `spec.children` [required] + +The immediate child groups of this group in the hierarchy (whose `parent` field +points to this group). The list must be present, but may be empty if there are +no child groups. The items are not guaranteed to be ordered in any particular +way. + +The entries of this array are +[entity references](https://backstage.io/docs/features/software-catalog/references), +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. + +### `spec.descendants` [required] + +The immediate and recursive child groups of this group in the hierarchy +(children, and children's children, etc.). The list must be present, but may be +empty if there are no child groups. The items are not guaranteed to be ordered +in any particular way. + +The entries of this array are +[entity references](https://backstage.io/docs/features/software-catalog/references), +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: User + +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `User` | + +A user describes a person, such as an employee, a contractor, or similar. Users +belong to [`Group`](#kind-group) entities in the catalog. + +These catalog user entries are connected to the way that authentication within +the Backstage ecosystem works. See the [auth](https://backstage.io/docs/auth) +section of the docs for a discussion of these concepts. + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: jdoe +spec: + profile: + displayName: Jenny Doe + email: jenny-doe@example.com + picture: https://example.com/staff/jenny-with-party-hat.jpeg + memberOf: [team-b, employees] +``` + +In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata) +shape, this kind has the following structure. + +### `apiVersion` and `kind` [required] + +Exactly equal to `backstage.io/v1alpha1` and `User`, respectively. + +### `spec.profile` [optional] + +Optional profile information about the user, mainly for display purposes. All +fields of this structure are also optional. The email would be a primary email +of some form, that the user may wish to be used for contacting them. The picture +is expected to be a URL pointing to an image that's representative of the user, +and that a browser could fetch and render on a profile page or similar. + +### `spec.memberOf` [required] + +The list of groups that the user is a direct member of (i.e., no transitive +memberships are listed here). The list must be present, but may be empty if the +user is not member of any groups. The items are not guaranteed to be ordered in +any particular way. + +The entries of this array are +[entity references](https://backstage.io/docs/features/software-catalog/references), +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. diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index 4e8a357ea7..b045660202 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -16,11 +16,9 @@ Backstage natively supports tracking of the following component ![](../../assets/software-catalog/bsc-extend.png) Since these types are likely not the only kind of software you will want to -track in Backstage, it is possible to - -It is possible to add your own software types that fits 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. +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 @@ -30,7 +28,7 @@ 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 advice against this; firstly, +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 engineers have when describing your software. Secondly, Backstage helps your engineers manage their software by integrating the infrastructure tooling diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md index 49a490d78b..4ba83146db 100644 --- a/docs/features/software-catalog/index.md +++ b/docs/features/software-catalog/index.md @@ -84,13 +84,13 @@ registered in the catalog. ### Static catalog configuration In addition to manually registering components, it is also possible to register -components though [static configuration](../../conf/index.md). For example, the +components through [static configuration](../../conf/index.md). For example, the above example can be added using the following configuration: ```yaml catalog: locations: - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml ``` diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index a825020eb6..26990033aa 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -166,21 +166,21 @@ our example templates through static configuration. Add the following to the catalog: locations: # Backstage Example Component - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/podcast-api-component.yaml - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/queue-proxy-component.yaml - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/searcher-component.yaml - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-lib-component.yaml - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/www-artist-component.yaml - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/shuffle-api-component.yaml ``` diff --git a/docs/features/software-catalog/references.md b/docs/features/software-catalog/references.md new file mode 100644 index 0000000000..e438ddc977 --- /dev/null +++ b/docs/features/software-catalog/references.md @@ -0,0 +1,104 @@ +--- +id: references +title: Entity References +description: How to express references between entities +--- + +Entities commonly have a need to reference other entities. For example, a +[Component](descriptor-format.md#kind-component) entity may want to declare who +its owner is by mentioning a Group or User entity, and a User entity may want to +declare what Group entities it is a member of. This article describes how to +write those references in your yaml entity declaration files. + +Each entity in the catalog is uniquely identified by the triplet of its +[kind](descriptor-format.md#apiversion-and-kind-required), +[namespace](descriptor-format.md#namespace-optional), and +[name](descriptor-format.md#name-required). But that's a lot to type out +manually, and in a lot of circumstances, both the kind and the namespace are +fixed, or possible to deduce, or could have sane default values. So in order to +help the writer, the catalog has a few tricks up its sleeve. + +Each reference can be expressed in one of two ways: as a compact string, or as a +compound reference structure. + +## String References + +This is the most common alternative, that should be used in almost all +circumstances. + +The string is on the form `[:][/]`, that is, it is +composed of between one and three parts in this specific order, without any +additional encoding: + +- Optionally, the kind, followed by a colon +- Optionally, the namespace, followed by a forward slash +- The name + +The name is always required. Depending on the context, you may be able to leave +out the kind and/or namespace. If you do, it is contextual what values will be +used, and the relevant documentation should specify which rule applies where. +All strings are case insensitive. + +```yaml +# Example: +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: petstore + namespace: external-systems + description: Petstore +spec: + type: service + lifecycle: experimental + owner: group:pet-managers + implementsApis: + - petstore + - internal/streetlights + - hello-world +``` + +The field `spec.owner` is a reference. In this case, the string +`group:pet-managers` was given by the user. That means that the kind is `Group`, +the namespace is left out, and the name is `pet-managers`. In this context, the +namespace was chosen to fall back to the value `default` by the code that parsed +the reference, so the end result is that we expect to find another entity in the +catalog that is of kind `Group`, namespace `default` (which, actually, also can +be left out in its own yaml file because that's the default value there too), +and name `pet-managers`. + +The entries in `implementsApis` are also references. In this case, none of them +needs to specify a kind since we know from the context that that's the only kind +that's supported here. The second entry specifies a namespace but the other ones +don't, and in this context, the default is to refer to the same namespace as the +originating entity (`external-systems` here). So the three references +essentially expand to `api:external-systems/petstore`, +`api:internal/streetlights`, and `api:external-systems/hello-world`. We expect +there to exist three API kind entities in the catalog matching those references. + +## Compound References + +This is a more verbose version of a reference, where each part of the +kind-namespace-name triplet is expressed as a field in a structure. This format +can be used where necessary, such as if either of the three elements contains +colons or forward slashes. Avoid using it where possible, since it is harder to +read and write for humans. + +```yaml +# Example: +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: petstore + description: Petstore +spec: + type: service + lifecycle: experimental + owner: + kind: Group + name: aegis-imports/pet-managers +``` + +In this example, the `spec.owner` has been broken apart since the name was +complex. The kind happened to be written with an uppercase letter G, which also +works. The namespace was left out just like in the string version above, which +is handled identically. diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index ba5b491e07..757e0461a9 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -41,6 +41,27 @@ 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 @@ -79,13 +100,50 @@ metadata: ``` The value of this annotation is the so-called slug that identifies a project on -[GitHub](https://github.com) that is related to this entity. It is on the format +[GitHub](https://github.com) (either the public one, or a private GitHub +Enterprise installation) that is related to this entity. It is on the format `/`, and is the same as can be seen in the URL location bar of the browser when viewing that project. Specifying this annotation will enable GitHub related features in Backstage for that entity. +### github.com/team-slug + +```yaml +# Example: +metadata: + annotations: + github.com/team-slug: spotify/backstage-core +``` + +The value of this annotation is the so-called slug that identifies a team on +[GitHub](https://github.com) (either the public one, or a private GitHub +Enterprise installation) that is related to this entity. It is on the format +`/`, and is the same as can be seen in the URL location bar +of the browser when viewing that team. + +This annotation can be used on a [Group entity](descriptor-format.md#kind-group) +to note that it originated from that team on GitHub. + +### github.com/user-login + +```yaml +# Example: +metadata: + annotations: + github.com/user-login: freben +``` + +The value of this annotation is the so-called login that identifies a user on +[GitHub](https://github.com) (either the public one, or a private GitHub +Enterprise installation) that is related to this entity. It is on the format +``, and is the same as can be seen in the URL location bar of the +browser when viewing that user. + +This annotation can be used on a [User entity](descriptor-format.md#kind-user) +to note that it originated from that user on GitHub. + ### sentry.io/project-slug ```yaml @@ -121,6 +179,21 @@ fallback (`rollbar.organization` followed by `organization.name`). Specifying this annotation may enable Rollbar related features in Backstage for that entity. +### backstage.io/ldap-rdn, backstage.io/ldap-uuid, backstage.io/ldap-dn + +```yaml +# Example: +metadata: + annotations: + backstage.io/ldap-rdn: my-team + backstage.io/ldap-uuid: c57e8ba2-6cc4-1039-9ebc-d5f241a7ca21 + backstage.io/ldap-dn: cn=my-team,ou=access,ou=groups,ou=spotify,dc=spotify,dc=net +``` + +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. + ## Deprecated Annotations The following annotations are deprecated, and only listed here to aid in diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index fad5c68e41..5a4e0049ab 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -5,7 +5,7 @@ description: Documentation on Adding your own Templates --- Templates are stored in the **Service Catalog** under a kind `Template`. The -minimum that the a template skeleton needs is a `template.yaml` but it would be +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. A simple `template.yaml` definition might look something like this: @@ -30,7 +30,7 @@ spec: templater: cookiecutter # what does this template create type: website - # if the template is not in the current directory where this definition is kept then specfiy + # if the template is not in the current directory where this definition is kept then specify path: './template' # the schema for the form which is displayed in the frontend. # should follow JSON schema for forms: https://jsonforms.io/ @@ -66,7 +66,7 @@ for example ```yaml catalog: locations: - - type: github + - type: url target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml rules: - allow: [Template] @@ -88,7 +88,7 @@ running: ```sh curl \ --location \ - --request POST 'localhost:7000/catalog/locations' \ + --request POST 'localhost:7000/api/catalog/locations' \ --header 'Content-Type: application/json' \ --data-raw "{\"type\": \"file\", \"target\": \"${YOUR PATH HERE}/template.yaml\"}" ``` @@ -98,7 +98,7 @@ If loading from a Git location, you can run the following ```sh curl \ --location \ - --request POST 'localhost:7000/catalog/locations' \ + --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\"}" ``` @@ -107,7 +107,7 @@ This should then have added the catalog, and also should now 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 be come the `PreparerKey` which will be used to +the Service Catalog here, will become the `PreparerKey` which will be used to select the `Preparer` when creating a job. ### Adding form values in the Scaffolder Wizard 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 14c75b0ffb..80287c6ce1 100644 --- a/docs/features/software-templates/extending/create-your-own-preparer.md +++ b/docs/features/software-templates/extending/create-your-own-preparer.md @@ -17,7 +17,7 @@ location protocols: These two are added to the `PreparersBuilder` and then passed into the `createRouter` function of the `@spotify/plugin-scaffolder-backend` -An full example backend can be found +A full example backend can be found [here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), but it looks something like the following @@ -88,7 +88,7 @@ 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 -### Registerinng your own Preparer +### Registering your own Preparer You can register the preparer that you have created with the `PreparerBuilder` by using the `PreparerKey` from the Catalog, for example like this: @@ -98,4 +98,4 @@ const preparers = new Preparers(); preparers.register('gcs', new GoogleCloudStoragePreparer()); ``` -And then pass this in to the `createRouter` function. +And then pass this into the `createRouter` function. 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 25d7bcf676..ecb097f9d9 100644 --- a/docs/features/software-templates/extending/create-your-own-publisher.md +++ b/docs/features/software-templates/extending/create-your-own-publisher.md @@ -8,10 +8,10 @@ Publishers are responsible for pushing and storing the templated skeleton after the values have been templated by the `Templater`. See [Create your own templater](./create-your-own-templater.md) for more info. -They receive a directory or location where the templater has sucessfully run and -is now ready to store somewhere. They also are given some other options which -are sent from the frontend, such as the `storePath` which is a string of where -the frontend thinks we should save this templated folder. +They receive a directory or location where the templater has successfully run +and is now ready to store somewhere. They also are given some other options +which are sent from the frontend, such as the `storePath` which is a string of +where the frontend thinks we should save this templated folder. Currently we provide the following `publishers`: 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 36c362b9c3..d4a6eef5d2 100644 --- a/docs/features/software-templates/extending/create-your-own-templater.md +++ b/docs/features/software-templates/extending/create-your-own-templater.md @@ -147,4 +147,4 @@ const templaters = new Templaters(); templaters.register('handlebars', new HandlebarsTemplater()); ``` -And then pass this in to the `createRouter` function. +And then pass this into the `createRouter` function. diff --git a/docs/features/software-templates/extending/index.md b/docs/features/software-templates/extending/index.md index 02d27a5725..944bf0e3ca 100644 --- a/docs/features/software-templates/extending/index.md +++ b/docs/features/software-templates/extending/index.md @@ -11,13 +11,13 @@ You're at the right place. This guide is going to take you through how the Scaffolder in Backstage works. We'll dive into some jargon and run through what's going on in the backend to be able to create these templates. There's also more guides that you might find -useful at the bottom of this document. At it's core, theres 3 simple stages. +useful at the bottom of this document. At its core, there are 3 simple stages. 1. Pick a skeleton 2. Template some variables into the skeleton 3. Send the templated skeleton somewhere -These three steps are translated to the folllowing stages under the hood in the +These three steps are translated to the following stages under the hood in the scaffolder that you will need to know: 1. Prepare @@ -38,7 +38,7 @@ the router to pick the correct `Preparer` to run for the `Template` entity. **Templater** - The templater is responsible for actually running the chosen templater on top of the previously returned temporary directory from the -**Preprarer**. We advise making these Docker containers as it can keep all +**Preparer**. We advise making these Docker containers as it can keep all dependencies--for example Cookiecutter--self contained and not a dependency on the host machine. @@ -82,7 +82,7 @@ Once that has been posted, a job will be setup with different stages, and the job processor will complete each stage before moving onto the next stage, whilst collecting logs and mutating the running job. -Here's some futher reading that you might find useful: +Here's some further reading that you might find useful: - [Adding your own Template](../adding-templates.md) - [Creating your own Templater](./create-your-own-templater.md) diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 5453c4befa..60f73ad0c3 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -94,7 +94,7 @@ import { GitlabPublisher, CreateReactAppTemplater, Templaters, - RepoVisilityOptions, + RepoVisibilityOptions, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; import { Gitlab } from '@gitbeaker/node'; @@ -126,7 +126,7 @@ export default async function createPlugin({ const githubToken = config.getString('scaffolder.github.token'); const repoVisibility = config.getString( 'scaffolder.github.visibility', - ) as RepoVisilityOptions; + ) as RepoVisibilityOptions; const githubClient = new Octokit({ auth: githubToken }); const githubPublisher = new GithubPublisher({ @@ -191,13 +191,13 @@ our example templates through static configuration. Add the following to the catalog: locations: # Backstage Example Templates - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml - - type: github + - type: url target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml ``` @@ -217,6 +217,11 @@ 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 @@ -227,21 +232,31 @@ instance: scaffolder: github: token: - $secret: - env: GITHUB_ACCESS_TOKEN + $env: GITHUB_ACCESS_TOKEN visibility: public # or 'internal' or 'private' gitlab: api: baseUrl: https://gitlab.com token: - $secret: - env: SCAFFOLDER_GITLAB_PRIVATE_TOKEN + $env: SCAFFOLDER_GITLAB_PRIVATE_TOKEN ``` -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. +#### Azure DevOps + +For Azure DevOps we support both the preparer and publisher stage with the +configuration of a private access token (PAT). For the publisher it's also +required to define the base URL for the client to connect to the service. This +will hopefully support on-prem installations as well but that has not been +verified. + +```yaml +scaffolder: + azure: + baseUrl: https://dev.azure.com/{your-organization} + api: + token: + $env: AZURE_PRIVATE_TOKEN +``` ### Running the Backend diff --git a/docs/features/techdocs/FAQ.md b/docs/features/techdocs/FAQ.md index 5d6c9136d9..e81e9d82c7 100644 --- a/docs/features/techdocs/FAQ.md +++ b/docs/features/techdocs/FAQ.md @@ -11,14 +11,15 @@ This page answers frequently asked questions about [TechDocs](README.md). - [What static site generator is TechDocs using?](#what-static-site-generator-is-techdocs-using) - [What is the mkdocs-techdocs-core plugin?](#what-is-the-mkdocs-techdocs-core-plugin) -- [Does TechDocs support file formats other than Markdown (e.g. rst, asciidoc)?](#does-techdocs-support-file-formats-other-than-markdown-eg-rst-asciidoc-) +- [Does TechDocs support file formats other than Markdown (e.g. RST, AsciiDoc)?](#does-techdocs-support-file-formats-other-than-markdown-eg-rst-asciidoc-) #### What static site generator is TechDocs using? 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. +is using the MkDocs +[Material Theme](https://github.com/squidfunk/mkdocs-material). #### What is the mkdocs-techdocs-core plugin? @@ -29,9 +30,9 @@ plugins (e.g. [MkDocs Monorepo Plugin](https://github.com/spotify/mkdocs-monorepo-plugin)) as well as a selection of Python Markdown extensions that TechDocs supports. -#### Does TechDocs support file formats other than Markdown (e.g. rst, asciidoc) ? +#### Does TechDocs support file formats other than Markdown (e.g. RST, AsciiDoc) ? Not right now. We are currently using MkDocs to generate the documentation from -source. So, they have to be in Markdown format. However, in future we want to -support other alternatives to MkDocs. That will make it possible to use other -file formats. +source, so the files have to be in Markdown format. However, in the future we +want to support other static site generators which will make it possible to use +other file formats. diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index aa97506edb..cec5b63cc7 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -33,16 +33,17 @@ about TechDocs and the philosophy in its ## Project roadmap -| Version | Description | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| [TechDocs V.0 ✅][v0] | Read docs in Backstage - Enable anyone to get a reader experience working in Backstage. [See V.0 Use Cases.](#techdocs-v0) | -| [TechDocs V.1 ✅][v1] | TechDocs end to end (alpha) - Alpha of TechDocs that you can use end to end - and contribute to. [See V.1 Use Cases.](#techdocs-v1) | -| [TechDocs V.2 🔮⌛][v2] | Platform stability and compatibility improvements. [See V.2 Use Cases.](#techdocs-v2) | -| TechDocs V.3 🔮⌛ | Widget Architecture - TechDocs widget architecture available, so the community can create their own customized features. | +| Version | Description | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [TechDocs V.0 ✅][v0] | Read docs in Backstage - Enable anyone to get a reader experience working in Backstage. [See V.0 Use Cases.](#techdocs-v0) | +| [TechDocs V.1 ✅][v1] | TechDocs end to end (alpha) - Alpha of TechDocs that you can use end to end - and contribute to. [See V.1 Use Cases.](#techdocs-v1) | +| [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/17 +[v2]: https://github.com/spotify/backstage/milestone/22 +[v3]: https://github.com/spotify/backstage/milestone/17 @@ -59,26 +60,39 @@ about TechDocs and the philosophy in its - As a user I can run TechDocs locally and read documentation. - As a user I can create a docs folder in my entity project and add a reference in the entity configuration file (of the owning entity) to my documentation. - - Backstage will automatically build my documentation and serve it in - TechDocs. - - Documentation will be displayed under the docs tab in the service catalog. +- Backstage will automatically build my documentation and serve it in TechDocs. +- Documentation will be displayed under the docs tab in the service catalog. - As a user I can create a docs only repository that will be standalone from any other service. - As a user I can choose my own storage solution for the documentation (as example GCS/AWS/Azure etc) - As a user I can define my own API to interface my own documentation solution. -#### TechDocs V.2 - -Platform stability and compatibility improvements +Extra platform stability and compatibility improvements: - As a user I can define the metadata generated for my documentation. - As a user I will be able to browse metadata from within my documentation in Backstage. +#### TechDocs V.2 + +We have a TechDocs that works end-to-end. The next step is to make it super easy +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. +- Enable companies to choose their own storage (S3 for example). +- Enable companies to choose their own source code hosting provider (GitHub, + GitLab, and so). +- Share how to plug in TechDocs to your own CI. + #### TechDocs V.3 -more to come... +Build a widget (plugin) framework so that contributors can easily contribute +features to TechDocs - that others can use. And, also, so that we can easily +migrate Spotify's existing TechDocs features to open source. ## Structure diff --git a/docs/features/techdocs/concepts.md b/docs/features/techdocs/concepts.md index f13f9933f5..13144f8df0 100644 --- a/docs/features/techdocs/concepts.md +++ b/docs/features/techdocs/concepts.md @@ -25,7 +25,14 @@ MkDocs. [TechDocs Container](https://github.com/spotify/backstage/blob/master/packages/techdocs-container/README.md) -### TechDocs publisher (coming soon) +### TechDocs publisher + +The `techdocs-backend` plugin currently comes with one publisher - +`LocalPublish`. + +[TechDocs Backend](https://github.com/spotify/backstage/tree/master/plugins/techdocs-backend) + +More standalone publishers will come in the near future... ### TechDocs CLI diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index b9d215fd8a..2e5c8d000d 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -13,7 +13,7 @@ If you haven't setup Backstage already, start ## Installing TechDocs TechDocs is provided with the Backstage application by default. If you want to -set up TechDocs manually, keep follow the instructions below. +set up TechDocs manually, keep following the instructions below. ### Adding the package @@ -57,8 +57,8 @@ The default storage and request URLs: ```yaml techdocs: - storageUrl: http://localhost:7000/techdocs/static/docs - requestUrl: http://localhost:7000/techdocs/docs + storageUrl: http://localhost:7000/api/techdocs/static/docs + requestUrl: http://localhost:7000/api/techdocs/docs ``` If you want `techdocs-backend` to manage building and publishing, you want diff --git a/docs/features/techdocs/troubleshooting.md b/docs/features/techdocs/troubleshooting.md new file mode 100644 index 0000000000..3a9b2fcfd0 --- /dev/null +++ b/docs/features/techdocs/troubleshooting.md @@ -0,0 +1,10 @@ +--- +id: troubleshooting +title: Troubleshooting TechDocs +sidebar_label: Troubleshooting +description: Troubleshooting for TechDocs +--- + +- TechDocs will fail to clone your docs if you have a git config which overrides + the `https` protocol with `ssh` or something else. Make sure to remove your + git config locally when you try TechDocs. diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 452e90add5..9be0e24452 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -38,6 +38,34 @@ app-folder is the name that was provided when prompted. Inside that directory, it will generate all the files and folder structure needed for you to run your app. +### Troubleshooting + +The create app command doesn't always work as expected, this is a collection of +some of the commonly encountered issues and solutions. + +#### Couldn't find any versions for "file-saver" + +You may encounter the following error message: + +```text +Couldn't find any versions for "file-saver" that matches "eligrey-FileSaver.js-1.3.8.tar.gz-art-external" +``` + +This is likely because you have a globally configured NPM proxy, which breaks +the installation of the `material-table` dependency. This is a known issue and +being worked on in `material-table`, but for now you can work around it using +the following: + +```bash +NPM_CONFIG_REGISTRY=https://registry.npmjs.org npx @backstage/create-app +``` + +#### Can't find Python executable "python" + +The install process may also fail if no Python installation is available. Python +is commonly available in most systems already, but if it isn't you can head for +example [here](https://www.python.org/downloads/) to install it. + ### General folder structure Below is a simplified layout of the files and folders generated when creating an @@ -46,6 +74,7 @@ app. ``` app ├── app-config.yaml +├── catalog-info.yaml ├── lerna.json ├── package.json └── packages @@ -55,6 +84,9 @@ app - **app-config.yaml**: Main configuration file for the app. See [Configuration](https://backstage.io/docs/conf/) for more information. +- **catalog-info.yaml**: Catalog Entities descriptors. See + [Descriptor Format of Catalog Entities](https://backstage.io/docs/features/software-catalog/descriptor-format) + to get started. - **lerna.json**: Contains information about workspaces and other lerna configuration needed for the monorepo setup. - **package.json**: Root package.json for the project. _Note: Be sure that you diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index 154a959f85..aa9aa0bde1 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -88,3 +88,17 @@ yarn create-plugin # Create a new plugin > See > [package.json](https://github.com/spotify/backstage/blob/master/package.json) > for other yarn commands/options. + +## Local configuration + +Backstage allows you to specify the configuration used while running the +application on your computer. Local configuration is read from +`app-config.local.yaml`. This file is ignored by Git, which means that you can +safely use it to reference secrets like GitHub tokens without worrying about +these secrets, inadvertently ending up in the Git repository. You do not need to +copy everything from the default config to the local config. +`app-config.local.yaml` will be merged with `app-config.yaml` and overwrite the +default app configs. + +You can learn more about the local configuration in +[Static Configuration in Backstage](../conf/) section. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index a9ac770f90..4ca2f431a7 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -24,9 +24,11 @@ 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), and [yarn](https://classic.yarnpkg.com/en/docs/install). You -will also need to have [Docker](https://docs.docker.com/engine/install/) -installed to use some features like Software Templates and TechDocs. +(currently v12), [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 +like Software Templates and TechDocs. Using `npx` you can then run the following to create an app in a chosen subdirectory of your current working directory: diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md index a0cf10998e..6e6c8b2733 100644 --- a/docs/getting-started/running-backstage-locally.md +++ b/docs/getting-started/running-backstage-locally.md @@ -42,7 +42,7 @@ of GitHub and run an initial build. ```bash # Start from your local development folder -git clone git@github.com:spotify/backstage.git +git clone --depth 1 git@github.com:spotify/backstage.git cd backstage # Fetch our dependencies and run an initial build diff --git a/docs/openapi/definitions/auth.yaml b/docs/openapi/definitions/auth.yaml index eb96d434c2..cb736fd2a9 100644 --- a/docs/openapi/definitions/auth.yaml +++ b/docs/openapi/definitions/auth.yaml @@ -6,10 +6,10 @@ info: **Provided by `@backstage/auth-backend`.** - The purpose of the Auth APIs in Backstage are to identify the user, and to provide a way for plugins + The purpose of the Auth APIs in Backstage are to identify the user, and to provide a way for plugins to request access to 3rd party services on behalf of that user. - The API is supplied with a list of providers - such as `Google` or `Github` - and will add the endpoints + 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). @@ -21,7 +21,7 @@ externalDocs: description: Backstage official documentation url: https://github.com/spotify/backstage/blob/master/docs/README.md servers: - - url: http://localhost:7000/auth/ + - url: http://localhost:7000/api/auth/ tags: - name: provider description: List of endpoints per provider diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index f2c6dad61f..8edf3e9e4c 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -4,6 +4,23 @@ title: Architecture overview description: Documentation on Architecture overview --- +## Terminology + +Backstage is constructed out of three parts. We separate Backstage in this way +because we see three groups of contributors that work with Backstage in three +different ways. + +- Core - Base functionality built by core developers in the open source project. +- App - The app is an instance of a Backstage app that is deployed and tweaked. + The app ties together core functionality with additional plugins. The app is + built and maintained by app developers, usually a productivity team within a + company. +- Plugins - Additional functionality to make your Backstage app useful for your + company. Plugins can be specific to a company or open sourced and reusable. At + Spotify we have over 100 plugins built by over 50 different teams. It has been + very powerful to get contributions from various infrastructure teams added + into a single unified developer experience. + ## Overview The following diagram shows how Backstage might look when deployed inside a diff --git a/docs/overview/architecture-terminology.md b/docs/overview/architecture-terminology.md deleted file mode 100644 index 092ca7fb01..0000000000 --- a/docs/overview/architecture-terminology.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -id: architecture-terminology -title: Architecture terminology -description: Documentation on Architecture terminology ---- - -Backstage is constructed out of three parts. We separate Backstage in this way -because we see three groups of contributors that work with Backstage in three -different ways. - -- Core - Base functionality built by core devs in the open source project. -- App - The app is an instance of a Backstage app that is deployed and tweaked. - The app ties together core functionality with additional plugins. The app is - built and maintained by app developers, usually a productivity team within a - company. -- Plugins - Additional functionality to make your Backstage app useful for your - company. Plugins can be specific to a company or open sourced and reusable. At - Spotify we have over 100 plugins built by over 50 different teams. It has been - very powerful to get contributions from various infrastructure teams added - into a single unified developer experience. diff --git a/docs/overview/background.md b/docs/overview/background.md index 486260bac3..aca46614c2 100644 --- a/docs/overview/background.md +++ b/docs/overview/background.md @@ -1,7 +1,8 @@ --- id: background title: The Spotify Story -description: Documentation on Background and Story behind making of Backstage +description: Backstage was born out of necessity at Spotify. We found that as we grew, our +infrastructure was becoming more fragmented, our engineers less productive. --- Backstage was born out of necessity at Spotify. We found that as we grew, our @@ -29,3 +30,9 @@ building a new microservice using an automated template in Backstage. Create, maintain, and find the documentation for all that software in Backstage. One place for everything. Accessible to everyone. + +Backstage was originally built by Spotify and then donated to the CNCF. +Backstage is currently in the Sandbox phase. Read the announcement +[here](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox). + + diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index d6c1d2d641..1c90916fb5 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -27,10 +27,11 @@ We have divided the project into three high-level _phases_: With a single catalog, Backstage makes it easy for a team to manage ten services — and makes it possible for your company to manage thousands of them. -- 🐇 **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. +- 🐇 **Phase 3:** Ecosystem (ongoing, see + [Plugin Marketplace](https://backstage.io/plugins)) - 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. ## Detailed roadmap @@ -53,31 +54,24 @@ guidelines to get started. 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. + +- **[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)** - + 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. -- **[TechDocs v1](https://github.com/spotify/backstage/milestone/16)** - Our - docs-like-code feature TechDocs working end to end. - -- **[Initial GraphQL API](https://github.com/spotify/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. - -- **Production deployments** - Provide instructions and default configurations - (e.g. through Helm charts) for easy deployments of Backstage and its - subsystems on Kubernetes. - -- **Cloud Cost Insights plugin (from Spotify)** - Spotify teams are fully - responsible for their own software, including the cost of the cloud resources - they use. By making our internal cost insights plugin available as open source - you will also be able to treat cost as an engineering problem, and make it - easy for your engineers to see their spend and where there's opportunity to - reduce waste. - - Further improvements to platform documentation ### Plugins @@ -96,10 +90,6 @@ Chances are that someone will jump in and help build it. ### Future work 🔮 -- **[Backstage platform is stable](https://github.com/spotify/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. - - **Deploy a product demo at `demo.backstage.io`** - Deploy a typical Backstage deployment available publicly so that people can click around and get a feel for the product without having to install anything. @@ -118,8 +108,16 @@ Chances are that someone will jump in and help build it. [AWS](https://github.com/spotify/backstage/issues/290), [Azure](https://github.com/spotify/backstage/issues/348) and others. +- **[Initial GraphQL API](https://github.com/spotify/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. + ### Completed milestones ✅ +- [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) - [Plugin marketplace](https://backstage.io/plugins) - [Improved and move documentation to backstage.io](https://backstage.io/docs/overview/what-is-backstage) - [Backstage Service Catalog (alpha)](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha) diff --git a/docs/overview/support.md b/docs/overview/support.md index 6e00c17fab..12bc7cfaf9 100644 --- a/docs/overview/support.md +++ b/docs/overview/support.md @@ -13,10 +13,7 @@ description: Support and Community Details and Links - [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) +- [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 â¤ī¸ - -Or, if you are an open source developer and are interested in joining our team, -please reach out to -[foss-opportunities@spotify.com ](mailto:foss-opportunities@spotify.com) diff --git a/docs/overview/what-is-backstage.md b/docs/overview/what-is-backstage.md index 0d16943c9b..3d41c714ee 100644 --- a/docs/overview/what-is-backstage.md +++ b/docs/overview/what-is-backstage.md @@ -1,7 +1,7 @@ --- id: what-is-backstage title: What is Backstage? -description: Backsatge is an open platform for building developer portals. +description: Backstage is an open platform for building developer portals. Powered by a centralized service catalog, Backstage restores order to your microservices and infrastructure --- @@ -33,7 +33,14 @@ Out of the box, Backstage includes: [open source plugins](https://github.com/spotify/backstage/tree/master/plugins) that further expand Backstage’s customizability and functionality -### Benefits +## Backstage and the CNCF + +Backstage is a CNCF Sandbox project. Read the announcement +[here](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox). + + + +## Benefits - For _engineering managers_, it allows you to maintain standards and best practices across the organization, and can help you manage your whole tech diff --git a/docs/plugins/existing-plugins.md b/docs/plugins/existing-plugins.md index c1fbda379a..3c1ccb6438 100644 --- a/docs/plugins/existing-plugins.md +++ b/docs/plugins/existing-plugins.md @@ -4,12 +4,15 @@ title: Existing plugins description: Lists of existing open source plugins --- -## Open source plugins +## The Plugin Marketplace -The full list of open source plugins can be found -[here](https://github.com/spotify/backstage/tree/master/plugins). +Open source plugins that you can add to your Backstage deployment can be found +at: -## Plugin gallery +https://backstage.io/plugins -TODO: In the future we would like to have something similar to -https://grafana.com/grafana/plugins +![](https://backstage.io/blog/assets/marketplace.png) + +## Links + +- [[blog] The Plugin Marketplace is open](https://backstage.io/blog/2020/09/30/plugin-marketplace) diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 8a61aeee80..8836c1cd42 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -9,7 +9,7 @@ Backstage is a single-page application composed of a set of plugins. Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong -[design guidelines](../dls/design.md) we ensure the the overall user experience +[design guidelines](../dls/design.md) we ensure the overall user experience stays consistent between plugins. ![plugin](../assets/my-plugin_screenshot.png) @@ -21,7 +21,7 @@ To create a plugin, follow the steps outlined [here](create-a-plugin.md). ## Suggesting a plugin If you start developing a plugin that you aim to release as open source, we -suggest that you create a new +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. diff --git a/docs/plugins/proxying.md b/docs/plugins/proxying.md index 2f809ffd10..3d9aa90447 100644 --- a/docs/plugins/proxying.md +++ b/docs/plugins/proxying.md @@ -23,7 +23,7 @@ const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const service = createServiceBuilder(module) .loadConfig(configReader) /** ... other routers ... */ - .addRouter('/proxy', await proxy(proxyEnv, '/proxy')); + .addRouter('/proxy', await proxy(proxyEnv)); ``` ## Configuration @@ -41,15 +41,14 @@ proxy: target: http://larger.example.com:8080/svc.v1 headers: Authorization: - $secret: - env: EXAMPLE_AUTH_HEADER + $env: EXAMPLE_AUTH_HEADER ``` Each key under the proxy configuration entry is a route to match, below the prefix that the proxy plugin is mounted on. It must start with a slash. For example, if the backend mounts the proxy plugin as `/proxy`, the above configuration will lead to the proxy acting on backend requests to -`/proxy/simple-example/...` and `/proxy/larger-example/v1/...`. +`/api/proxy/simple-example/...` and `/api/proxy/larger-example/v1/...`. The value inside each route is either a simple URL string, or an object on the format accepted by @@ -74,6 +73,6 @@ except with the following caveats for convenience: commonly useful value. - If `pathRewrite` is not specified, it is set to a single rewrite that removes the entire prefix and route. In the above example, a rewrite of - `'^/proxy/larger-example/v1/': '/'` is added. That means that a request to - `/proxy/larger-example/v1/some/path` will be translated to a request to + `'^/api/proxy/larger-example/v1/': '/'` is added. That means that a request to + `/api/proxy/larger-example/v1/some/path` will be translated to a request to `http://larger.example.com:8080/svc.v1/some/path`. diff --git a/docs/plugins/publishing.md b/docs/plugins/publishing.md index 0ed85e8cdc..fbeab6340c 100644 --- a/docs/plugins/publishing.md +++ b/docs/plugins/publishing.md @@ -21,6 +21,9 @@ out a new branch that you will use for the release, e.g. $ git checkout -b new-release ``` +First bump the `CHANGELOG.md` in the root of the repo and commit. You bump it by +adding a header for the new version just below the `## Next Release` one. + Then, from the root of the repo, run ```sh diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index f061d2f4a8..ee0a27cde2 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -84,7 +84,7 @@ structure our plugins. There are usually one or multiple page components and next to them you can split up the UI in as many components as you feel like. We have the `ExamplePage` to show an example Backstage page component. The -`ExampleFetchComponent` show cases the common task of making an async request to +`ExampleFetchComponent` showcases the common task of making an async request to a public API and plot the response data in a table using Material-UI components. You may tweak these components, rename them and/or replace them completely. diff --git a/docs/plugins/testing.md b/docs/plugins/testing.md index 696f8b1368..c6e1599786 100644 --- a/docs/plugins/testing.md +++ b/docs/plugins/testing.md @@ -32,7 +32,7 @@ working on. ## Naming Test Files -Tests should be name `[filename].test.js`. +Tests should be named `[filename].test.js`. For example, the tests for **`Link.js`** exist in the file **`Link.test.js`**. @@ -54,7 +54,7 @@ TODO. # Writing Unit Tests -The following principles are good guides to determining if you are writing high +The following principles are good guides for determining if you are writing high quality frontend unit tests. ## Bad Unit Test Principle @@ -166,11 +166,11 @@ because it fulfills all the principles above: ✓ **Fulfills Input/Output Principle**: Verifies the output changes when the input changes -✓ **Fufills Blackbox Principle**: Does not verify _how_ the `` +✓ **Fulfills Blackbox Principle**: Does not verify _how_ the `` component is mounted, just that it is mounted in response to the input. ✓ **Fulfills Scalability Principle**: If we decide to refactor the entire way -the loading indicator is displayed the test still works without touching it. +the loading indicator has displayed the test still works without touching it. ✓ **Fulfills Broken Functionality Principle**: this test verifies the functionality (displaying an indicator) is working, rather than how it is diff --git a/docs/reference/utility-apis/AlertApi.md b/docs/reference/utility-apis/AlertApi.md index d000a6d6a2..6219f520f5 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AlertApi.ts#L29). +[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). 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AlertApi.ts#L19). +[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). 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). Referenced by: [alert\$](#alert). ### Observer -This file contains non-react related core types used throught Backstage. +This file contains non-react related core types used through 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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 e7d5296ceb..eb35eb38ef 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L50). +[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). The following Utility API implements this type: [appThemeApiRef](./README.md#apptheme) @@ -76,7 +76,7 @@ export type AppTheme = { Defined at -[packages/core-api/src/apis/definitions/AppThemeApi.ts:24](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L24). +[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). Referenced by: [getInstalledThemes](#getinstalledthemes). @@ -87,7 +87,7 @@ export type BackstagePalette = Palette & Palette Defined at -[packages/theme/src/types.ts:70](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/theme/src/types.ts#L70). +[packages/theme/src/types.ts:70](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/theme/src/types.ts#L70). Referenced by: [BackstageTheme](#backstagetheme). @@ -100,7 +100,7 @@ export interface BackstageTheme extends Theme { Defined at -[packages/theme/src/types.ts:73](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/theme/src/types.ts#L73). +[packages/theme/src/types.ts:73](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/theme/src/types.ts#L73). Referenced by: [AppTheme](#apptheme). @@ -129,13 +129,13 @@ 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/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). Referenced by: [activeThemeId\$](#activethemeid). ### Observer -This file contains non-react related core types used throught Backstage. +This file contains non-react related core types used through Backstage. Observer interface for consuming an Observer, see TC39. @@ -148,7 +148,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/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -204,7 +204,7 @@ type PaletteAdditions = { Defined at -[packages/theme/src/types.ts:23](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/theme/src/types.ts#L23). +[packages/theme/src/types.ts:23](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/theme/src/types.ts#L23). Referenced by: [BackstagePalette](#backstagepalette). @@ -227,6 +227,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/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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 87318ecff5..529b73c576 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:144](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L144). +[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). The following Utility APIs implement this type: @@ -15,6 +15,8 @@ The following Utility APIs implement this type: - [microsoftAuthApiRef](./README.md#microsoftauth) +- [oauth2ApiRef](./README.md#oauth2) + - [oktaAuthApiRef](./README.md#oktaauth) ## Members @@ -66,7 +68,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L40). +[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). Referenced by: [getBackstageIdentity](#getbackstageidentity). @@ -87,6 +89,6 @@ export type BackstageIdentity = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:157](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L157). +[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). Referenced by: [getBackstageIdentity](#getbackstageidentity). diff --git a/docs/reference/utility-apis/Config.md b/docs/reference/utility-apis/Config.md index 9374fb9962..b54e122c03 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L32). +[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L18). +[packages/config/src/types.ts:18](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L17). +[packages/config/src/types.ts:17](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/config/src/types.ts#L19). +[packages/config/src/types.ts:19](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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 39902789cd..24371c1729 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L30). +[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). 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 c05f060ec3..762d2016d5 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ErrorApi.ts#L53). +[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). 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ErrorApi.ts#L24). +[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). 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ErrorApi.ts#L33). +[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). 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L53). +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). Referenced by: [error\$](#error). ### Observer -This file contains non-react related core types used throught Backstage. +This file contains non-react related core types used through 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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 5efdb176a4..8fbcb794fb 100644 --- a/docs/reference/utility-apis/FeatureFlagsApi.md +++ b/docs/reference/utility-apis/FeatureFlagsApi.md @@ -1,7 +1,7 @@ # FeatureFlagsApi The FeatureFlagsApi type is defined at -[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:41](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L41). +[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). The following Utility API implements this type: [featureFlagsApiRef](./README.md#featureflags) diff --git a/docs/reference/utility-apis/IdentityApi.md b/docs/reference/utility-apis/IdentityApi.md index 4e37ad5300..5ee5c582b6 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/IdentityApi.ts#L22). +[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). The following Utility API implements this type: [identityApiRef](./README.md#identity) @@ -40,12 +40,12 @@ identity, such as a demo user or mocked user for e2e tests. getIdToken(): Promise<string | undefined> -### logout() +### signOut() -Log out the current user +Sign out the current user
-logout(): Promise<void>
+signOut(): Promise<void>
 
## Supporting types @@ -76,6 +76,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L172). +[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). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/OAuthApi.md b/docs/reference/utility-apis/OAuthApi.md index e73d5f645f..a489db76c5 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L67). +[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). The following Utility APIs implement this type: @@ -50,14 +50,6 @@ getAccessToken( ): Promise<string> -### logout() - -Log out the user's session. This will reload the page. - -
-logout(): Promise<void>
-
- ## Supporting types These types are part of the API declaration, but may not be unique to this API. @@ -90,7 +82,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L40). +[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). Referenced by: [getAccessToken](#getaccesstoken). @@ -116,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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L38). +[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). Referenced by: [getAccessToken](#getaccesstoken). diff --git a/docs/reference/utility-apis/OAuthRequestApi.md b/docs/reference/utility-apis/OAuthRequestApi.md index c6b9e09189..1328aabeec 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99). +[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). The following Utility API implements this type: [oauthRequestApiRef](./README.md#oauthrequest) @@ -11,7 +11,8 @@ The following Utility API implements this type: ### createAuthRequester() A utility for showing login popups or similar things, and merging together -multiple requests for different scopes into one request that inclues all scopes. +multiple requests for 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. @@ -30,7 +31,7 @@ createAuthRequester<AuthResponse>( ### authRequest\$() -Observers panding auth requests. The returned observable will emit all current +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 @@ -72,7 +73,7 @@ export type AuthProvider = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27). +[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). Referenced by: [AuthRequesterOptions](#authrequesteroptions), [PendingAuthRequest](#pendingauthrequest). @@ -96,7 +97,7 @@ export type AuthRequester<AuthResponse> = ( Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66). +[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). Referenced by: [createAuthRequester](#createauthrequester). @@ -121,7 +122,7 @@ export type AuthRequesterOptions<AuthResponse> = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43). +[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). Referenced by: [createAuthRequester](#createauthrequester). @@ -150,13 +151,13 @@ 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/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). Referenced by: [authRequest\$](#authrequest). ### Observer -This file contains non-react related core types used throught Backstage. +This file contains non-react related core types used through Backstage. Observer interface for consuming an Observer, see TC39. @@ -169,7 +170,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/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -204,7 +205,7 @@ export type PendingAuthRequest = { Defined at -[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77). +[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). Referenced by: [authRequest\$](#authrequest). @@ -227,6 +228,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/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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 41a3247af6..efd79593d6 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:104](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L104). +[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). The following Utility APIs implement this type: @@ -34,14 +34,6 @@ user rejects the login request. getIdToken(options?: AuthRequestOptions): Promise<string> -### logout() - -Log out the user's session. This will reload the page. - -
-logout(): Promise<void>
-
- ## Supporting types These types are part of the API declaration, but may not be unique to this API. @@ -74,6 +66,6 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L40). +[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). Referenced by: [getIdToken](#getidtoken). diff --git a/docs/reference/utility-apis/ProfileInfoApi.md b/docs/reference/utility-apis/ProfileInfoApi.md index 09c0f88f83..402b5ba504 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:127](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L127). +[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). The following Utility APIs implement this type: @@ -65,7 +65,7 @@ export type AuthRequestOptions = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L40). +[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). Referenced by: [getProfile](#getprofile). @@ -93,6 +93,6 @@ export type ProfileInfo = { Defined at -[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L172). +[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). Referenced by: [getProfile](#getprofile). diff --git a/docs/reference/utility-apis/README.md b/docs/reference/utility-apis/README.md index cfdc3b6ef8..71931a5d5a 100644 --- a/docs/reference/utility-apis/README.md +++ b/docs/reference/utility-apis/README.md @@ -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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AlertApi.ts#L41) +[alertApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/AppThemeApi.ts#L74) +[appThemeApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/AppThemeApi.ts#L74) ### auth0Auth @@ -29,11 +29,10 @@ Provides authentication towards Auth0 APIs Implemented types: [OpenIdConnectApi](./OpenIdConnectApi.md), [ProfileInfoApi](./ProfileInfoApi.md), -[BackstageIdentityApi](./BackstageIdentityApi.md), -[SessionStateApi](./SessionStateApi.md) +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[auth0AuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L273) +[auth0AuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L275) ### config @@ -42,7 +41,7 @@ Used to access runtime configuration Implemented type: [Config](./Config.md) ApiRef: -[configApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ConfigApi.ts#L22) +[configApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/ConfigApi.ts#L22) ### discovery @@ -51,7 +50,7 @@ Provides service discovery of backend plugins Implemented type: [DiscoveryApi](./DiscoveryApi.md) ApiRef: -[discoveryApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L44) +[discoveryApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/DiscoveryApi.ts#L44) ### error @@ -60,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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) +[errorApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/ErrorApi.ts#L65) ### featureFlags @@ -69,7 +68,7 @@ Used to toggle functionality in features across Backstage Implemented type: [FeatureFlagsApi](./FeatureFlagsApi.md) ApiRef: -[featureFlagsApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L58) +[featureFlagsApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L58) ### githubAuth @@ -77,11 +76,10 @@ Provides authentication towards GitHub APIs Implemented types: [OAuthApi](./OAuthApi.md), [ProfileInfoApi](./ProfileInfoApi.md), -[BackstageIdentityApi](./BackstageIdentityApi.md), -[SessionStateApi](./SessionStateApi.md) +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[githubAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L230) +[githubAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L232) ### gitlabAuth @@ -89,11 +87,10 @@ Provides authentication towards GitLab APIs Implemented types: [OAuthApi](./OAuthApi.md), [ProfileInfoApi](./ProfileInfoApi.md), -[BackstageIdentityApi](./BackstageIdentityApi.md), -[SessionStateApi](./SessionStateApi.md) +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[gitlabAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L260) +[gitlabAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L262) ### googleAuth @@ -102,11 +99,10 @@ Provides authentication towards Google APIs and identities Implemented types: [OAuthApi](./OAuthApi.md), [OpenIdConnectApi](./OpenIdConnectApi.md), [ProfileInfoApi](./ProfileInfoApi.md), -[BackstageIdentityApi](./BackstageIdentityApi.md), -[SessionStateApi](./SessionStateApi.md) +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[googleAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L213) +[googleAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L215) ### identity @@ -115,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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/IdentityApi.ts#L54) +[identityApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/IdentityApi.ts#L54) ### microsoftAuth @@ -124,11 +120,10 @@ Provides authentication towards Microsoft APIs and identities Implemented types: [OAuthApi](./OAuthApi.md), [OpenIdConnectApi](./OpenIdConnectApi.md), [ProfileInfoApi](./ProfileInfoApi.md), -[BackstageIdentityApi](./BackstageIdentityApi.md), -[SessionStateApi](./SessionStateApi.md) +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[microsoftAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L287) +[microsoftAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L289) ### oauth2 @@ -136,10 +131,11 @@ Example of how to use oauth2 custom provider Implemented types: [OAuthApi](./OAuthApi.md), [OpenIdConnectApi](./OpenIdConnectApi.md), -[ProfileInfoApi](./ProfileInfoApi.md), [SessionStateApi](./SessionStateApi.md) +[ProfileInfoApi](./ProfileInfoApi.md), +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[oauth2ApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L301) +[oauth2ApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L303) ### oauthRequest @@ -148,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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) +[oauthRequestApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130) ### oktaAuth @@ -157,11 +153,10 @@ Provides authentication towards Okta APIs Implemented types: [OAuthApi](./OAuthApi.md), [OpenIdConnectApi](./OpenIdConnectApi.md), [ProfileInfoApi](./ProfileInfoApi.md), -[BackstageIdentityApi](./BackstageIdentityApi.md), -[SessionStateApi](./SessionStateApi.md) +[BackstageIdentityApi](./BackstageIdentityApi.md), [SessionApi](./SessionApi.md) ApiRef: -[oktaAuthApiRef](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/auth.ts#L243) +[oktaAuthApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/apis/definitions/auth.ts#L245) ### storage @@ -170,4 +165,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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/StorageApi.ts#L68) +[storageApiRef](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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 new file mode 100644 index 0000000000..e7a9e58c59 --- /dev/null +++ b/docs/reference/utility-apis/SessionApi.md @@ -0,0 +1,138 @@ +# 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). + +The following Utility APIs implement this type: + +- [auth0AuthApiRef](./README.md#auth0auth) + +- [githubAuthApiRef](./README.md#githubauth) + +- [gitlabAuthApiRef](./README.md#gitlabauth) + +- [googleAuthApiRef](./README.md#googleauth) + +- [microsoftAuthApiRef](./README.md#microsoftauth) + +- [oauth2ApiRef](./README.md#oauth2) + +- [oktaAuthApiRef](./README.md#oktaauth) + +## Members + +### signIn() + +Sign in with a minimum set of permissions. + +
+signIn(): Promise<void>
+
+ +### signOut() + +Sign out from the current session. This will reload the page. + +
+signOut(): Promise<void>
+
+ +### sessionState\$() + +Observe the current state of the auth session. Emits the current state on +subscription. + +
+sessionState$(): Observable<SessionState>
+
+ +## Supporting types + +These types are part of the API declaration, but may not be unique to this API. + +### Observable + +Observable sequence of values and errors, see TC39. + +https://github.com/tc39/proposal-observable + +This is used as a common return type for observable values and can be created +using many different observable implementations, such as zen-observable or +RxJS 5. + +
+export type Observable<T> = {
+  /**
+   * Subscribes to this observable to start receiving new values.
+   */
+  subscribe(observer: Observer<T>): Subscription;
+  subscribe(
+    onNext: (value: T) => void,
+    onError?: (error: Error) => void,
+    onComplete?: () => void,
+  ): Subscription;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). + +Referenced by: [sessionState\$](#sessionstate). + +### Observer + +This file contains non-react related core types used through Backstage. + +Observer interface for consuming an Observer, see TC39. + +
+export type Observer<T> = {
+  next?(value: T): void;
+  error?(error: Error): void;
+  complete?(): void;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). + +Referenced by: [Observable](#observable). + +### SessionState + +Session state values passed to subscribers of the SessionApi. + +
+export enum SessionState {
+  SignedIn = 'SignedIn',
+  SignedOut = 'SignedOut',
+}
+
+ +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). + +Referenced by: [sessionState\$](#sessionstate). + +### Subscription + +Subscription returned when subscribing to an Observable, see TC39. + +
+export type Subscription = {
+  /**
+   * Cancels the subscription
+   */
+  unsubscribe(): void;
+
+  /**
+   * Value indicating whether the subscription is closed.
+   */
+  readonly closed: Boolean;
+}
+
+ +Defined at +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/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 c1821f2381..81f25aa349 100644 --- a/docs/reference/utility-apis/SessionStateApi.md +++ b/docs/reference/utility-apis/SessionStateApi.md @@ -62,7 +62,7 @@ Referenced by: [sessionState\$](#sessionstate). ### Observer -This file contains non-react related core types used throught Backstage. +This file contains non-react related core types used through Backstage. Observer interface for consuming an Observer, see TC39. diff --git a/docs/reference/utility-apis/StorageApi.md b/docs/reference/utility-apis/StorageApi.md index bee52935da..e7d5131ff9 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[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). The following Utility API implements this type: [storageApiRef](./README.md#storage) @@ -35,7 +35,7 @@ remove(key: string): Promise<void> ### set() -Save persistant data, and emit messages to anyone that is using observe\$ for +Save persistent data, and emit messages to anyone that is using observe\$ for this key
@@ -79,13 +79,13 @@ 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/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L53). Referenced by: [observe\$](#observe), [StorageApi](#storageapi). ### Observer -This file contains non-react related core types used throught Backstage. +This file contains non-react related core types used through 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L24). +[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L24). Referenced by: [Observable](#observable). @@ -129,7 +129,7 @@ export interface StorageApi { remove(key: string): Promise<void>; /** - * 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. */ @@ -144,7 +144,7 @@ export interface StorageApi { Defined at -[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/StorageApi.ts#L31). +[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). 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/apis/definitions/StorageApi.ts#L21). +[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). 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/82d329555c16af46db9b4e5cd2f44a3cc006a52e/packages/core-api/src/types.ts#L33). +[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/0406ace29aba7332a98ff9ef9feedd65adc75223/packages/core-api/src/types.ts#L33). Referenced by: [Observable](#observable). diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md index 24765e8531..908d7fc61b 100644 --- a/docs/tutorials/journey.md +++ b/docs/tutorials/journey.md @@ -134,7 +134,7 @@ work. # 5. The First User At this point the only things that anyone that wants to use Sam's plugin needs -to do is to add +to do are to add https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id to their catalog schema, import and add the `PlayerWidget` on the desired entity template pages, and make sure they're providing Spotify auth. @@ -145,7 +145,7 @@ select a theme tune when creating a new component. Sam jumps on the idea and adds a new creation hook that is exported by the plugin. The hook can be installed either in a single, all, or component templates that match a label. It adds a field as a part of the component creation process with a nice search box -that allow users to search for a track that they want to use as the theme tune. +that allows users to search for a track that they want to use as the theme tune. # 6. Return to the Repo @@ -203,7 +203,7 @@ backend plugin also extends the common GraphQL schema with a mutation that updates the track ID in the database. On the frontend the Pull Request doesn't change much. It defines the save action -the was previously using the `RepoApi` in it's own API. +the was previously using the `RepoApi` in its own API. ```ts type ThemeTuneStorageApi = { @@ -247,7 +247,7 @@ once an internal concern of the plugin is now becoming a standard in the community. In order to standardize the annotation in Backstage, Sam submits a Pull Request -to the Backstage Core repo. The request suggest a new well-known metadata +to the Backstage Core repo. The request suggests a new well-known metadata annotation called `spotify.com/track-id`, with the same schema definition as Sam's label, and refers to Sam's own plugin and the `spotify-album-art` plugin as existing usages. The Backstage maintainers merge the Pull Request, after diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md index 2615e37633..8f6b35617b 100644 --- a/docs/tutorials/quickstart-app-auth.md +++ b/docs/tutorials/quickstart-app-auth.md @@ -11,10 +11,11 @@ title: Monorepo App Setup With Authentication > 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. 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 here > +> This document assumes you have NodeJS 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 +> here > > [simple-backstage-app](https://github.com/johnson-jesse/simple-backstage-app). # The Skeleton Application @@ -71,15 +72,12 @@ auth: github: development: clientId: - $secret: - env: AUTH_GITHUB_CLIENT_ID + $env: AUTH_GITHUB_CLIENT_ID clientSecret: - $secret: - env: AUTH_GITHUB_CLIENT_SECRET + $env: AUTH_GITHUB_CLIENT_SECRET ## uncomment the following three lines if using enterprise # enterpriseInstanceUrl: - # $secret: - # env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL + # $env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL ``` 2. Set environment variables in whatever fashion is easiest for you. I chose to @@ -115,7 +113,7 @@ export AUTH_GITHUB_CLIENT_SECRET=xxx > Log into http://github.com > Navigate to (Settings > Developer Settings > OAuth Apps > New OAuth App)[https://github.com/settings/applications/new] > Set Homepage URL = http://localhost:3000 -> Set Callback URL = http://localhost:7000/auth/github +> Set Callback URL = http://localhost:7000/api/auth/github > Click [Register application] > On the next page, copy and paste your new Client ID and Client Secret to the environment variables above, `AUTH_GITHUB_CLIENT_ID` & `AUTH_GITHUB_CLIENT_SECRET` > Don't forget to `source` that profile file again if necessary. @@ -155,42 +153,11 @@ const app = createApp({ }); ``` -6. Open and change _root > packages > app > src >_ `apis.ts` as follows +7. Start the backend and frontend as before -```ts -// Add the following imports to the existing list from core -import { githubAuthApiRef, GithubAuth } from '@backstage/core'; -``` - -7. In the same file, change the builder block for oauthRequestApiRef as follows - -_from:_ - -```ts -builder.add(oauthRequestApiRef, new OAuthRequestManager()); -``` - -_to:_ - -```ts -const oauthRequestApi = builder.add( - oauthRequestApiRef, - new OAuthRequestManager(), -); - -builder.add( - githubAuthApiRef, - GithubAuth.create({ - discoveryApi, - oauthRequestApi, - }), -); -``` - -> Start the backend and frontend as before. When the browser loads, you should -> be presented with a login page for GitHub. Login as usual with your GitHub -> account. If this is your first time, you will be asked to authorize and then -> are redirected to the catalog page if all is well. +When the browser loads, you should be presented with a login page for GitHub. +Login as usual with your GitHub account. If this is your first time, you will be +asked to authorize and then are redirected to the catalog page if all is well. # Where to go from here diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 28efb2a007..9f571b0c9a 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -20,10 +20,11 @@ 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. 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 here > +> This document assumes you have NodeJS 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 +> here > > [simple-backstage-app-plugin](https://github.com/johnson-jesse/simple-backstage-app-plugin). # The Skeleton Plugin @@ -34,7 +35,7 @@ title: Adding Custom Plugin to Existing Monorepo App 1. When the process finishes, let's start the backend: `yarn --cwd packages/backend start` 1. If you see errors starting, refer to - [Auth Configuration](https://github.com/johnson-jesse/simple-backstage-app/blob/master/README.md#the-auth-configuration) + [Auth Configuration](https://backstage.io/docs/tutorials/quickstart-app-auth#the-auth-configuration) for more information on environment variables. 1. And now the frontend, from a new terminal window and the root of your project: `yarn start` @@ -119,67 +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 + 6. Here is the entire file for reference -
Complete ExampleComponent.tsx -

- -```tsx -import React, { FC } from 'react'; -import { Typography, Grid } from '@material-ui/core'; -import { - InfoCard, - Header, - Page, - pageTheme, - Content, - ContentHeader, - HeaderLabel, - SupportButton, - identityApiRef, -} from '@backstage/core'; -import { useApi } from '@backstage/core-api'; -import ExampleFetchComponent from '../ExampleFetchComponent'; - -const ExampleComponent: FC<{}> = () => { - const identityApi = useApi(identityApiRef); - const userId = identityApi.getUserId(); - const profile = identityApi.getProfile(); - - return ( - -

- - -
- - - A description of your plugin goes here. - - - - - - {`${profile.displayName} | ${profile.email}`} - - - - - - - - - - ); -}; - -export default ExampleComponent; -``` - -

-
+ [ExampleComponent.tsx](https://github.com/spotify/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md) # The Wipe @@ -188,7 +132,7 @@ changes, let's start by wiping this component clean. 1. Start by opening `root: plugins > github-playground > src > components > ExampleFetchComponent > ExampleFetchComponent.tsx` -1. Replace everyting in the file with the following: +1. Replace everything in the file with the following: ```tsx import React, { FC } from 'react'; @@ -267,7 +211,7 @@ type Viewer = { }; ``` -# The Tabel Model +# The Table Model Using Backstage's own component library, let's define a custom table. This component will get used if we have data to display. @@ -329,8 +273,8 @@ const { value, loading, error } = useAsync(async (): Promise => { }, []); ``` -4. The resolved data is conventiently destructured with value containing our - Viewer type. loading as a boolean, self explainatory. And error which is +4. The resolved data is conveniently destructured with `value` containing our + Viewer type. `loading` as a boolean, self explanatory. And `error` which is present only if necessary. So let's use those as the first 3 of 4 multi return statements. 5. Add the _if return_ blocks below our async block @@ -358,123 +302,10 @@ 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 -
Complete ExampleFetchComponent.tsx -

- -```tsx -import React, { FC } from 'react'; -import { useAsync } from 'react-use'; -import Alert from '@material-ui/lab/Alert'; -import { - Table, - TableColumn, - Progress, - githubAuthApiRef, -} from '@backstage/core'; -import { useApi } from '@backstage/core-api'; -import { graphql } from '@octokit/graphql'; - -const query = `{ -viewer { - repositories(first: 100) { - totalCount - nodes { - name - createdAt - description - diskUsage - isFork - } - pageInfo { - endCursor - hasNextPage - } - } -} -}`; - -type Node = { - name: string; - createdAt: string; - description: string; - diskUsage: number; - isFork: boolean; -}; - -type Viewer = { - repositories: { - totalCount: number; - nodes: Node[]; - pageInfo: { - endCursor: string; - hasNextPage: boolean; - }; - }; -}; - -type DenseTableProps = { - viewer: Viewer; -}; - -export const DenseTable: FC = ({ viewer }) => { - const columns: TableColumn[] = [ - { title: 'Name', field: 'name' }, - { title: 'Created', field: 'createdAt' }, - { title: 'Description', field: 'description' }, - { title: 'Disk Usage', field: 'diskUsage' }, - { title: 'Fork', field: 'isFork' }, - ]; - - return ( -

- ); -}; - -const ExampleFetchComponent: FC<{}> = () => { - const auth = useApi(githubAuthApiRef); - - const { value, loading, error } = useAsync(async (): Promise => { - const token = await auth.getAccessToken(); - - const gqlEndpoint = graphql.defaults({ - // Uncomment baseUrl if using enterprise - // baseUrl: 'https://github.MY-BIZ.com/api', - headers: { - authorization: `token ${token}`, - }, - }); - const { viewer } = await gqlEndpoint(query); - return viewer; - }, []); - - if (loading) return ; - if (error) return {error.message}; - if (value && value.repositories) return ; - - return ( -
- ); -}; - -export default ExampleFetchComponent; -``` - -

- - -10. We finished! If there are no errors, you should see your own GitHub - repoistory information displayed in a basic table. If you run into issues, - you can compare the repo that backs this documdnt, + [ExampleFetchComponent.tsx](https://github.com/spotify/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, [simple-backstage-app-plugin](https://github.com/johnson-jesse/simple-backstage-app-plugin) # Where to go from here @@ -482,6 +313,6 @@ export default ExampleFetchComponent; > Break apart ExampleFetchComponent into smaller logical parts contained in > their own files. Rename your components to something other than ExampleXxx. > -> You might be real proud of a plugin you develop. Follow this next tutorial for -> an in-depth look at publishing and including that for the entire Backstage +> You might be really proud of a plugin you develop. Follow this next tutorial +> for an in-depth look at publishing and including that for the entire Backstage > community. [TODO](#). diff --git a/lerna.json b/lerna.json index a018b1a289..7d514abb97 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "packages": ["packages/*", "plugins/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.1-alpha.22" + "version": "0.1.1-alpha.24" } diff --git a/microsite/.prettierignore b/microsite/.prettierignore new file mode 100644 index 0000000000..378eac25d3 --- /dev/null +++ b/microsite/.prettierignore @@ -0,0 +1 @@ +build diff --git a/microsite/blog/2020-09-23-backstage-cncf-sandbox.md b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md new file mode 100644 index 0000000000..bcfd7c6245 --- /dev/null +++ b/microsite/blog/2020-09-23-backstage-cncf-sandbox.md @@ -0,0 +1,21 @@ +--- +title: Backstage has been accepted into the CNCF Sandbox +author: Stefan Ålund +authorURL: https://twitter.com/stalund +--- + +**TL;DR** The Cloud Native Computing Foundation (CNCF) announced that Backstage can begin incubating as an early stage project in the [CNCF Sandbox](https://www.cncf.io/sandbox-projects/). Released open source in March, the platform is built around an advanced service catalog and is designed to streamline software development from end to end. + +![cncf](assets/cncf-sandbox/cncf.png) + + + +Backstage garnered quite a bit of interest from developers and organizations when it was first announced, and community interest continues to grow as plugins and new features are added with the open source community. We released the open source version of Backstage ‘early’. That was intentional. Because even though we’ve been using Backstage internally for years, we wanted the open source version to be developed with input and contributions from the community. And that’s exactly the product that’s going into the [CNCF Sandbox](https://www.cncf.io/sandbox-projects/) today. + +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). + +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. diff --git a/microsite/blog/2020-09-30-backstage-design-system.md b/microsite/blog/2020-09-30-backstage-design-system.md new file mode 100644 index 0000000000..4faea98782 --- /dev/null +++ b/microsite/blog/2020-09-30-backstage-design-system.md @@ -0,0 +1,107 @@ +--- +title: How to design for Backstage (even if you’re not a designer) +author: Kat Zhou +authorURL: http://twitter.com/katherinemzhou +--- + +![img](assets/backstage-DS-header.png) + +We are excited to launch the Backstage Design System, which includes a [Figma UI kit](https://www.figma.com/community/file/850673348101741100), a rich set of [reusable code components](http://backstage.io/storybook) on Storybook, and [Guidelines](https://backstage.io/docs/dls/design) for designers and developers as they build plugins for Backstage. + + + +## Not just the way it looks + +We see great design as one of the secret weapons of Backstage. Design should never be an afterthought — we believe an elegant, cohesive UX is vital to what makes Backstage such a productive, end-to-end development environment. + +Backstage keeps engineers from getting lost inside the complexity of your infrastructure by bringing order to your software ecosystem (through the [service catalog](https://backstage.io/blog/2020/06/22/backstage-service-catalog-alpha)) and providing an abstraction layer on top of it. How that abstraction layer works is essential to how Backstage works. So we’ve spent time figuring out how to make great design in Backstage easier to achieve for both designers and non-designers alike. + +Since making Backstage open source, we’ve been able to host design studios and user interviews with teams outside Spotify to get feedback and collaborate on a design system, together. (And we’re always keen on collaborating with more organizations.) + +![img](assets/Backstage-mural-DS.png) +_Screenshot of our design studio for Backstage Search, which included stakeholders inside and outside of Spotify._ + +As Backstage has gained traction, we’ve seen the importance of creating a scalable and coherent design system. It’s through the shared guidelines of our design system that we are able to maintain a unified ecosystem of plugins for our users, globally. Let’s get started by taking a closer look at the different design components of a plugin. + +## Anatomy of a plugin + +![](https://backstage.io/img/cards-plugins.png) + +Plugins are what provide the feature functionality in Backstage, allowing you to customize it to fit your infrastructure. They are integrated into Backstage's frontend, so that no matter what tool or service is being accessed, users are guaranteed a seamless experience. + +As you begin to [build your own plugins](https://backstage.io/docs/plugins/), we encourage you to explore the [open source plugins](https://backstage.io/plugins) we have available in Backstage, as reference. The anatomy of a plugin is simple. In the example below, we’ve highlighted a few of the standard components you should consider when designing your plugin. + +![img](assets/backstage-guide-DS.png) + +1. **Tabs.** Want to include multiple pages in your plugin? Use our tab component so users can easily navigate through your plugin. +2. **Title.** Plugin pages should always have a title. Subheads are optional. Typically, the ‘star’ icon is included in the overview tab, which allows the user to favorite the plugin, adding it to the side navigation. +3. **Cards.** Use the card components to display different kinds of content and functionality. You can follow our 12-column, responsive grid system to arrange the cards. +4. **Support.** There should always be a support button (tertiary style, with icon). There can also be a primary action button as well as a secondary one. +5. **Header.** Use the Backstage header to allow for users to easily understand what they’re looking at. You can include an optional subhead as well as other information. The color varies depending on the type of plugin you’ve built (stand-alone tool, service, app, website, etc.). + +Now that we’ve familiarized ourselves with the basic parts of a plugin, let’s take a look at some of the tools and resources that make designing plugins even easier. + +## Tools and resources + +The quickest way to start is by duplicating our [Figma UI kit](https://www.figma.com/community/file/850673348101741100), then clicking on the Create a Plugin page on the left-hand side to grab some templates. Feel free to experiment and play around! Figma is a fantastic, multiplayer tool that allows for designers and developers to co-create components and share specs with ease. We’re excited to see what you design and develop. + +As you’re designing and building your plugins, make sure to take advantage of these helpful resources. + +![img](assets/backstage-figma1-DS.png) +![img](assets/backstage-figma2-ds.png) + +### [Figma Community](https://www.figma.com/@backstage) + +We are thrilled to be using [Figma Community](https://www.figma.com/@backstage) to share our design assets. You can duplicate our UI kit and design your own plugin for Backstage. Figma Community is currently in beta, so this is a neat opportunity to be testing out a new way of working. At the moment, it doesn’t support auto-updating of duplicated files, so we’ll be announcing new versions of our UI kit on Discord in the #design channel. + +![img](assets/backstage-storybook-ds.png) + +### [Storybook](https://backstage.io/storybook/) + +You can view (and grab) our [reusable components on Storybook](https://backstage.io/storybook/). If you’d like to help build up our design system, you can also help us add components that we’ve designed to Storybook as well. We post newly designed components and patterns to GitHub as issues, where contributors can pick them up and add them to our Storybook so they’re available for the rest of the Backstage community. + +![img](assets/backstage-guidelines-ds.png) + +### [Guidelines](https://backstage.io/docs/dls/design) + +To keep up with our latest design guidelines, go to [Designing for Backstage](https://backstage.io/docs/dls/design). You’ll find more how-to’s and you can also learn more about our design philosophy and practices there. Down the line, we plan on including more in-depth component rules in this section (i.e., dos/don’ts, use cases, etc.). + +![img](assets/backstage-github-ds.png) + +### [GitHub](https://github.com/spotify/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. + +![img](assets/backstage-discord-DS.png) + +### [Discord](https://discord.com/invite/MUpMjP2) + +All design questions should be directed to the [#design](https://discord.com/channels/687207715902193673/696709358544879716) channel in [Discord](https://discord.com/invite/MUpMjP2). + +## What's next for Backstage design + +There are a lot of exciting things that we’re envisioning for Backstage and open source design at Spotify. These include: + +- Expanding our Backstage Design System by building on the UI kit and component library in Figma and Storybook + +* Collaborating with more of our amazing contributors to ensure our Backstage Design System works for everyone + +* Featuring rad plugins that folks have created, using our design system, in our Figma Community space + +* Building up our Guidelines by continuing to creating robust design documentation + +* Ensuring that we maintain accessible practices throughout our experience + +![img](assets/backstage-world-DS.png) + +## Get involved + +Designing in the open needs to be democratic and participatory, which is why we invite you to join in on the fun! There are a couple things you can do to get involved, such as: + +- **Build with us!** Are there components/plugins that you’d like to see in Backstage? Feel free to create an example/prototype of what you’re envisioning and create a UX component issue in our GitHub repo. + +- **Chat with us!** If you have questions, ideas, or puppy GIFs, feel free to reach out to us on Discord in the #design channel. + +- **Share with us!** One of our priorities is making Backstage more accessible, and we need your help. If you’ve got A11Y insights and ideas on how we can improve our product, please let us know! + +- **Work with us!** We are hiring for product designers to work on Backstage. We strongly stand for breaking traditional pipelines and elevating our caliber by hiring the best folks who are underrepresented minorities in tech today. Keen on joining? Know someone who has a knack for open source design and design systems? Send a direct message to @katz on Discord! diff --git a/microsite/blog/2020-09-30-plugin-marketplace.md b/microsite/blog/2020-09-30-plugin-marketplace.md new file mode 100644 index 0000000000..b7a799097f --- /dev/null +++ b/microsite/blog/2020-09-30-plugin-marketplace.md @@ -0,0 +1,43 @@ +--- +title: The Plugin Marketplace is open +author: Stefan Ålund +authorURL: https://twitter.com/stalund +--- + +Backstage has an ambitious goal: to provide engineers with the best possible developer experience. + +A great developer experience leads to happy, creative, and productive engineers. Our belief is that engineers should not have to be experts in various infrastructure tools or disciplines (e.g., machine learning or backend) to be productive. Infrastructure should be abstracted away, so that developers can spend more cycles building and testing, quickly and safely. Backstage unifies all your infrastructure tooling, services, and documentation to create a streamlined development environment from end to end. + +Now you may be thinking, “Yeah, sure, that sounds nice and all, but how does Backstage actually abstract away infrastructure?” The short answer: [plugins](https://backstage.io/plugins). + +![plugins](https://backstage.io/img/cards-plugins.png) + + + +Think of plugins as a mini version of an infrastructure tool or service — just the parts you need, made quick and easy. The advantage of using a Backstage plugin instead of a tool’s dedicated UI is that all your infrastructure is packaged into a “single pane of glass” instead of being spread out like independently built “islands”. Once you grow your infrastructure portfolio, the complexity really starts to add up. The Backstage plugin model allows you to add more tools without increasing the cognitive load for your users. + +Our goal for the plugin ecosystem is that anything can be a plugin. The definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following clear [design guidelines](https://backstage.io/blog/2020/09/30/backstage-design-system) we ensure the overall user experience stays consistent between plugins. If we all do our job well, the end user of Backstage does not see the boundaries between plugins. They are interacting with one consistent product — with many features. + +## Building an ecosystem + +Imagine a not-so-distant future where you walk up to Backstage, install it in your environment, and then choose from a gallery of existing, open source plugins that serve and match whatever infrastructure and software development needs you have inside your company. That means you can get started with Backstage and see the gains of improved productivity within days, not months. That is our vision for the Backstage plugin ecosystem. + +Running services on Kubernetes? There’s a plugin for that. Using Snyk for security scanning? There’s a plugin for that. Grafana, DataDog, or Rollbar for monitoring? There’s a plugin for that. Using Jenkins, CircleCI, GitHub Actions, or Travis CI? Pick the CI plugin of your choice. You get the picture. Regardless of your stack, there’s a plugin that you can use. + +Like this vision? So do we! But we can’t do it alone. We’ll need your help. For this vision to come true, we need to foster a community where many companies and individual developers build and contribute their plugins. Having been [accepted into the CNCF Sandbox](https://backstage.io/blog/2020/09/23/backstage-cncf-sandbox) provides us with the groundwork and guidance to broaden the community even faster. + +This grand vision is actually not that far off. Already today there is a growing ecosystem of Backstage plugins. To highlight all the amazing work that has been done by the community, and make it easier for Backstage adopters to see what plugins are available, we now have a Plugin Marketplace: [https://backstage.io/plugins](https://backstage.io/plugins) + +![marketplace](assets/marketplace.png) + +## Creating and suggesting new plugins + +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. + +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)! + +_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!)._ diff --git a/microsite/blog/assets/2/screen.gif b/microsite/blog/assets/2/screen.gif index a0d52a23b4..f790d639cc 100644 Binary files a/microsite/blog/assets/2/screen.gif and b/microsite/blog/assets/2/screen.gif differ diff --git a/microsite/blog/assets/20-05-20/Service_Catalog_MVP.png b/microsite/blog/assets/20-05-20/Service_Catalog_MVP.png index 4a50f17d15..386b195ea9 100644 Binary files a/microsite/blog/assets/20-05-20/Service_Catalog_MVP.png and b/microsite/blog/assets/20-05-20/Service_Catalog_MVP.png differ diff --git a/microsite/blog/assets/20-05-20/Service_Catalog_MVP_service.png b/microsite/blog/assets/20-05-20/Service_Catalog_MVP_service.png index 8067f665c9..1a48b1900c 100644 Binary files a/microsite/blog/assets/20-05-20/Service_Catalog_MVP_service.png and b/microsite/blog/assets/20-05-20/Service_Catalog_MVP_service.png differ diff --git a/microsite/blog/assets/20-05-20/tabs.png b/microsite/blog/assets/20-05-20/tabs.png index 4a5b11f6fe..71516b6846 100644 Binary files a/microsite/blog/assets/20-05-20/tabs.png and b/microsite/blog/assets/20-05-20/tabs.png differ diff --git a/microsite/blog/assets/20-07-01/auth-landing.png b/microsite/blog/assets/20-07-01/auth-landing.png index e00f4c2e43..064802c90f 100644 Binary files a/microsite/blog/assets/20-07-01/auth-landing.png and b/microsite/blog/assets/20-07-01/auth-landing.png differ diff --git a/microsite/blog/assets/20-07-01/auth-sidebar.png b/microsite/blog/assets/20-07-01/auth-sidebar.png index b089ab6c0a..7c72a96ab5 100644 Binary files a/microsite/blog/assets/20-07-01/auth-sidebar.png and b/microsite/blog/assets/20-07-01/auth-sidebar.png differ diff --git a/microsite/blog/assets/2020-08-05/cards.png b/microsite/blog/assets/2020-08-05/cards.png index 4779618b92..a3e038ce1a 100644 Binary files a/microsite/blog/assets/2020-08-05/cards.png and b/microsite/blog/assets/2020-08-05/cards.png differ diff --git a/microsite/blog/assets/2020-08-05/catalog.png b/microsite/blog/assets/2020-08-05/catalog.png index e9c0c65ade..b1f3680511 100644 Binary files a/microsite/blog/assets/2020-08-05/catalog.png and b/microsite/blog/assets/2020-08-05/catalog.png differ diff --git a/microsite/blog/assets/2020-08-05/create-component.png b/microsite/blog/assets/2020-08-05/create-component.png index 4d815393fc..7f0c630d58 100644 Binary files a/microsite/blog/assets/2020-08-05/create-component.png and b/microsite/blog/assets/2020-08-05/create-component.png differ diff --git a/microsite/blog/assets/2020-08-05/template-form.png b/microsite/blog/assets/2020-08-05/template-form.png index 5805243f59..0643a7f3e8 100644 Binary files a/microsite/blog/assets/2020-08-05/template-form.png and b/microsite/blog/assets/2020-08-05/template-form.png differ diff --git a/microsite/blog/assets/2020-08-05/templates.png b/microsite/blog/assets/2020-08-05/templates.png index e350d463f6..af76121a72 100644 Binary files a/microsite/blog/assets/2020-08-05/templates.png and b/microsite/blog/assets/2020-08-05/templates.png differ diff --git a/microsite/blog/assets/3/audit-list.png b/microsite/blog/assets/3/audit-list.png index 84b64f976a..df18b9923f 100644 Binary files a/microsite/blog/assets/3/audit-list.png and b/microsite/blog/assets/3/audit-list.png differ diff --git a/microsite/blog/assets/3/audit-view.png b/microsite/blog/assets/3/audit-view.png index a2e9716cf4..df5f5691b0 100644 Binary files a/microsite/blog/assets/3/audit-view.png and b/microsite/blog/assets/3/audit-view.png differ diff --git a/microsite/blog/assets/3/create-audit.png b/microsite/blog/assets/3/create-audit.png index cb28de73a4..2b22d66b18 100644 Binary files a/microsite/blog/assets/3/create-audit.png and b/microsite/blog/assets/3/create-audit.png differ diff --git a/microsite/blog/assets/3/lead-copy.png b/microsite/blog/assets/3/lead-copy.png index fbf247eec8..8476461909 100644 Binary files a/microsite/blog/assets/3/lead-copy.png and b/microsite/blog/assets/3/lead-copy.png differ diff --git a/microsite/blog/assets/3/lead.png b/microsite/blog/assets/3/lead.png index 4b60c4961c..639b5b4a9e 100644 Binary files a/microsite/blog/assets/3/lead.png and b/microsite/blog/assets/3/lead.png differ diff --git a/microsite/blog/assets/4/create-app.png b/microsite/blog/assets/4/create-app.png index 52dcc13097..5ff683e5d0 100644 Binary files a/microsite/blog/assets/4/create-app.png and b/microsite/blog/assets/4/create-app.png differ diff --git a/microsite/blog/assets/4/welcome.png b/microsite/blog/assets/4/welcome.png index 5de0d57098..e217fda699 100644 Binary files a/microsite/blog/assets/4/welcome.png and b/microsite/blog/assets/4/welcome.png differ diff --git a/microsite/blog/assets/5/lead.png b/microsite/blog/assets/5/lead.png index 657268fc09..615cf4d349 100644 Binary files a/microsite/blog/assets/5/lead.png and b/microsite/blog/assets/5/lead.png differ diff --git a/microsite/blog/assets/6/header.png b/microsite/blog/assets/6/header.png index 6908e40dbc..3c39971d36 100644 Binary files a/microsite/blog/assets/6/header.png and b/microsite/blog/assets/6/header.png differ diff --git a/microsite/blog/assets/Backstage-mural-DS.png b/microsite/blog/assets/Backstage-mural-DS.png new file mode 100644 index 0000000000..6e2021a37c Binary files /dev/null and b/microsite/blog/assets/Backstage-mural-DS.png differ diff --git a/microsite/blog/assets/announcing-techdocs/discover1.png b/microsite/blog/assets/announcing-techdocs/discover1.png index 5b23e64b43..33869b4d64 100644 Binary files a/microsite/blog/assets/announcing-techdocs/discover1.png and b/microsite/blog/assets/announcing-techdocs/discover1.png differ diff --git a/microsite/blog/assets/announcing-techdocs/discover2.png b/microsite/blog/assets/announcing-techdocs/discover2.png index 3737ee68db..e7ed8d39fe 100644 Binary files a/microsite/blog/assets/announcing-techdocs/discover2.png and b/microsite/blog/assets/announcing-techdocs/discover2.png differ diff --git a/microsite/blog/assets/announcing-techdocs/docs-in-backstage.png b/microsite/blog/assets/announcing-techdocs/docs-in-backstage.png index f3b724ff4f..ee8b4dead0 100644 Binary files a/microsite/blog/assets/announcing-techdocs/docs-in-backstage.png and b/microsite/blog/assets/announcing-techdocs/docs-in-backstage.png differ diff --git a/microsite/blog/assets/announcing-techdocs/feedback-loop1.png b/microsite/blog/assets/announcing-techdocs/feedback-loop1.png index 4e2d0de5df..d78144bded 100644 Binary files a/microsite/blog/assets/announcing-techdocs/feedback-loop1.png and b/microsite/blog/assets/announcing-techdocs/feedback-loop1.png differ diff --git a/microsite/blog/assets/announcing-techdocs/feedback-loop2.png b/microsite/blog/assets/announcing-techdocs/feedback-loop2.png index 516780e3ac..5177451c9e 100644 Binary files a/microsite/blog/assets/announcing-techdocs/feedback-loop2.png and b/microsite/blog/assets/announcing-techdocs/feedback-loop2.png differ diff --git a/microsite/blog/assets/announcing-techdocs/github-issues.png b/microsite/blog/assets/announcing-techdocs/github-issues.png index 3d5f3465c0..dfe345bd0a 100644 Binary files a/microsite/blog/assets/announcing-techdocs/github-issues.png and b/microsite/blog/assets/announcing-techdocs/github-issues.png differ diff --git a/microsite/blog/assets/announcing-techdocs/metrics.png b/microsite/blog/assets/announcing-techdocs/metrics.png index b332a65b0d..58e63951bf 100644 Binary files a/microsite/blog/assets/announcing-techdocs/metrics.png and b/microsite/blog/assets/announcing-techdocs/metrics.png differ diff --git a/microsite/blog/assets/backstage-DS-header.png b/microsite/blog/assets/backstage-DS-header.png new file mode 100644 index 0000000000..2637bcc2a3 Binary files /dev/null and b/microsite/blog/assets/backstage-DS-header.png differ diff --git a/microsite/blog/assets/backstage-discord-DS.png b/microsite/blog/assets/backstage-discord-DS.png new file mode 100644 index 0000000000..bfee45ff18 Binary files /dev/null and b/microsite/blog/assets/backstage-discord-DS.png differ diff --git a/microsite/blog/assets/backstage-figma1-DS.png b/microsite/blog/assets/backstage-figma1-DS.png new file mode 100644 index 0000000000..c2c32a00cb Binary files /dev/null and b/microsite/blog/assets/backstage-figma1-DS.png differ diff --git a/microsite/blog/assets/backstage-figma2-ds.png b/microsite/blog/assets/backstage-figma2-ds.png new file mode 100644 index 0000000000..d5e4fa7d9d Binary files /dev/null and b/microsite/blog/assets/backstage-figma2-ds.png differ diff --git a/microsite/blog/assets/backstage-github-ds.png b/microsite/blog/assets/backstage-github-ds.png new file mode 100644 index 0000000000..297056de6b Binary files /dev/null and b/microsite/blog/assets/backstage-github-ds.png differ diff --git a/microsite/blog/assets/backstage-guide-DS.png b/microsite/blog/assets/backstage-guide-DS.png new file mode 100644 index 0000000000..4d57201010 Binary files /dev/null and b/microsite/blog/assets/backstage-guide-DS.png differ diff --git a/microsite/blog/assets/backstage-guidelines-ds.png b/microsite/blog/assets/backstage-guidelines-ds.png new file mode 100644 index 0000000000..3176a694db Binary files /dev/null and b/microsite/blog/assets/backstage-guidelines-ds.png differ diff --git a/microsite/blog/assets/backstage-storybook-ds.png b/microsite/blog/assets/backstage-storybook-ds.png new file mode 100644 index 0000000000..94d255b154 Binary files /dev/null and b/microsite/blog/assets/backstage-storybook-ds.png differ diff --git a/microsite/blog/assets/backstage-world-DS.png b/microsite/blog/assets/backstage-world-DS.png new file mode 100644 index 0000000000..354bf62829 Binary files /dev/null and b/microsite/blog/assets/backstage-world-DS.png differ diff --git a/microsite/blog/assets/blog_1.png b/microsite/blog/assets/blog_1.png index f8c3516fa7..7667c54538 100644 Binary files a/microsite/blog/assets/blog_1.png and b/microsite/blog/assets/blog_1.png differ diff --git a/microsite/blog/assets/cncf-sandbox/cncf.png b/microsite/blog/assets/cncf-sandbox/cncf.png new file mode 100644 index 0000000000..2edfeb9c8e Binary files /dev/null and b/microsite/blog/assets/cncf-sandbox/cncf.png differ diff --git a/microsite/blog/assets/illustration.svg b/microsite/blog/assets/illustration.svg index 50e865ed4f..bba49c0449 100644 --- a/microsite/blog/assets/illustration.svg +++ b/microsite/blog/assets/illustration.svg @@ -1,104 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/blog/assets/marketplace.png b/microsite/blog/assets/marketplace.png new file mode 100644 index 0000000000..01132b6aa2 Binary files /dev/null and b/microsite/blog/assets/marketplace.png differ diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index 761862c1fc..8e3c4c086d 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -51,6 +51,7 @@ class Footer extends React.Component { Subscribe to our newsletter + CNCF Sandbox
More
diff --git a/microsite/data/plugins/cloud-build.yaml b/microsite/data/plugins/cloud-build.yaml new file mode 100644 index 0000000000..cbd3a3cb6c --- /dev/null +++ b/microsite/data/plugins/cloud-build.yaml @@ -0,0 +1,13 @@ +--- +title: Google Cloud Build +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 +iconUrl: https://avatars2.githubusercontent.com/u/38220399?s=400&v=4 +npmPackageName: '@backstage/plugin-cloudbuild' +tags: + - ci + - cd + - test diff --git a/microsite/data/plugins/cost-insights.yaml b/microsite/data/plugins/cost-insights.yaml new file mode 100644 index 0000000000..7950879833 --- /dev/null +++ b/microsite/data/plugins/cost-insights.yaml @@ -0,0 +1,11 @@ +--- +title: Cost Insights +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 +iconUrl: https://www.materialui.co/materialIcons/editor/monetization_on_white_192x192.png +npmPackageName: '@backstage/plugin-cost-insights' +tags: + - web diff --git a/microsite/data/plugins/firebase-functions.yaml b/microsite/data/plugins/firebase-functions.yaml new file mode 100644 index 0000000000..097ef00e50 --- /dev/null +++ b/microsite/data/plugins/firebase-functions.yaml @@ -0,0 +1,9 @@ +--- +title: Firebase Functions +author: roadie.io +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 +npmPackageName: '@roadiehq/backstage-plugin-firebase-functions' diff --git a/microsite/data/plugins/gcp-projects.yaml b/microsite/data/plugins/gcp-projects.yaml new file mode 100644 index 0000000000..7eff42af98 --- /dev/null +++ b/microsite/data/plugins/gcp-projects.yaml @@ -0,0 +1,13 @@ +--- +title: GCP Project Creator +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 +iconUrl: https://avatars1.githubusercontent.com/u/2810941?s=280&v=4 +npmPackageName: '@backstage/plugin-gcp-projects' +tags: + - cloud + - project + - resources diff --git a/microsite/package.json b/microsite/package.json index 734f1e4d49..335ada7212 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -7,13 +7,17 @@ "examples": "docusaurus-examples", "start": "docusaurus-start", "build": "docusaurus-build", + "prettier:check": "prettier --check .", "publish-gh-pages": "docusaurus-publish", "write-translations": "docusaurus-write-translations", "version": "docusaurus-version", "rename-version": "docusaurus-rename-version" }, "devDependencies": { - "docusaurus": "^2.0.0-alpha.64", - "js-yaml": "^3.14.0" - } + "@spotify/prettier-config": "^8.0.0", + "docusaurus": "^2.0.0-alpha.65", + "js-yaml": "^3.14.0", + "prettier": "^2.0.5" + }, + "prettier": "@spotify/prettier-config" } diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js index d891e05613..c6657c6a31 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -216,7 +216,7 @@ const Background = props => { - + Manage data pipelines diff --git a/microsite/pages/en/plugins.js b/microsite/pages/en/plugins.js index 0ef8791970..a8255bcd0d 100644 --- a/microsite/pages/en/plugins.js +++ b/microsite/pages/en/plugins.js @@ -29,7 +29,7 @@ const Plugins = () => (
-

Plugin marketplace

+

Plugin Marketplace

Open source plugins that you can add to your Backstage deployment. Learn how to build a plugin. @@ -39,7 +39,7 @@ const Plugins = () => ( className="PluginAddNewButton ButtonFilled" href={addPluginDocsLink} > - Add to marketplace + Add to Marketplace

@@ -93,7 +93,7 @@ const Plugins = () => ( }} > - Add to marketplace + Add to Marketplace

diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 8fa4935a38..7081e2bb1c 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -3,7 +3,6 @@ "Overview": [ "overview/what-is-backstage", "overview/architecture-overview", - "overview/architecture-terminology", "overview/roadmap", "overview/vision", "overview/background", @@ -43,6 +42,7 @@ "features/software-catalog/configuration", "features/software-catalog/system-model", "features/software-catalog/descriptor-format", + "features/software-catalog/references", "features/software-catalog/well-known-annotations", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", @@ -71,6 +71,7 @@ "features/techdocs/concepts", "features/techdocs/architecture", "features/techdocs/creating-and-publishing", + "features/techdocs/troubleshooting", "features/techdocs/faqs" ] } @@ -158,7 +159,8 @@ "architecture-decisions/adrs-adr005", "architecture-decisions/adrs-adr006", "architecture-decisions/adrs-adr007", - "architecture-decisions/adrs-adr008" + "architecture-decisions/adrs-adr008", + "architecture-decisions/adrs-adr009" ], "Contribute": ["../CONTRIBUTING"], "Support": ["overview/support"], diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index d7a3bd67d0..d034321d6c 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -25,7 +25,7 @@ const siteConfig = { fossWebsite: 'https://spotify.github.io/', // Google Analytics - gaTrackingId: 'UA-48912878-10', + gaTrackingId: 'UA-163836834-5', // For no header links in the top nav bar -> headerLinks: [], headerLinks: [ diff --git a/microsite/static/animations/backstage-logos-hero-8.gif b/microsite/static/animations/backstage-logos-hero-8.gif index b9a218f395..2d332d3f38 100644 Binary files a/microsite/static/animations/backstage-logos-hero-8.gif and b/microsite/static/animations/backstage-logos-hero-8.gif differ diff --git a/microsite/static/animations/backstage-plugin-icon-2.gif b/microsite/static/animations/backstage-plugin-icon-2.gif index f8e4107e55..e02a8089f4 100644 Binary files a/microsite/static/animations/backstage-plugin-icon-2.gif and b/microsite/static/animations/backstage-plugin-icon-2.gif differ diff --git a/microsite/static/animations/backstage-service-catalog-icon-1.gif b/microsite/static/animations/backstage-service-catalog-icon-1.gif index e888a46e90..72414a8a5d 100644 Binary files a/microsite/static/animations/backstage-service-catalog-icon-1.gif and b/microsite/static/animations/backstage-service-catalog-icon-1.gif differ diff --git a/microsite/static/animations/backstage-software-templates-icon-5.gif b/microsite/static/animations/backstage-software-templates-icon-5.gif index 8e885393de..a5332ab3c8 100644 Binary files a/microsite/static/animations/backstage-software-templates-icon-5.gif and b/microsite/static/animations/backstage-software-templates-icon-5.gif differ diff --git a/microsite/static/animations/backstage-speed-paradox-7.gif b/microsite/static/animations/backstage-speed-paradox-7.gif index 3112e7698f..b6f16d2394 100644 Binary files a/microsite/static/animations/backstage-speed-paradox-7.gif and b/microsite/static/animations/backstage-speed-paradox-7.gif differ diff --git a/microsite/static/animations/backstage-standards-paradox-4.gif b/microsite/static/animations/backstage-standards-paradox-4.gif index 9cd921cce2..775cc38336 100644 Binary files a/microsite/static/animations/backstage-standards-paradox-4.gif and b/microsite/static/animations/backstage-standards-paradox-4.gif differ diff --git a/microsite/static/animations/backstage-techdocs-icon-1.gif b/microsite/static/animations/backstage-techdocs-icon-1.gif index 40f4ca4dd6..72aba32b09 100644 Binary files a/microsite/static/animations/backstage-techdocs-icon-1.gif and b/microsite/static/animations/backstage-techdocs-icon-1.gif differ diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 79b8fd9de4..738478724f 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -844,6 +844,7 @@ code { width: 700px; margin: 20px auto; max-width: calc(100vw - 80px); + padding-bottom: 80px; } @media only screen and (max-width: 485px) { diff --git a/microsite/static/css/plugins.css b/microsite/static/css/plugins.css index a753e16047..9a8bbafa46 100644 --- a/microsite/static/css/plugins.css +++ b/microsite/static/css/plugins.css @@ -1,5 +1,5 @@ .PluginCard { - background-color: #272822; + background-color: #282828; height: 100%; padding: 16px; display: flex; @@ -53,13 +53,12 @@ .ButtonFilled { padding: 4px 8px; border-radius: 4px; - background-color: #36baa2; - color: white; + color: #69ddc7; margin-top: 36px; } .ButtonFilled:hover { - border: 1px solid #36baa2; + border: 1px solid #69ddc7; background-color: transparent; } @@ -67,8 +66,8 @@ font-size: small; border-radius: 16px; padding: 2px 8px; - border: 1px solid #36baa2; - color: #36baa2; + border: 1px solid #69ddc7; + color: #69ddc7; } .PluginCardLink { @@ -112,5 +111,5 @@ } #add-plugin-card { - border: 1px solid #36baa2; + border: 1px solid #69ddc7; } diff --git a/microsite/static/img/android-chrome-192x192.png b/microsite/static/img/android-chrome-192x192.png index 4660f988c1..6b1af9bd95 100644 Binary files a/microsite/static/img/android-chrome-192x192.png and b/microsite/static/img/android-chrome-192x192.png differ diff --git a/microsite/static/img/android-chrome-512x512.png b/microsite/static/img/android-chrome-512x512.png index d9b0a6ba78..736ff90f8b 100644 Binary files a/microsite/static/img/android-chrome-512x512.png and b/microsite/static/img/android-chrome-512x512.png differ diff --git a/microsite/static/img/apple-touch-icon.png b/microsite/static/img/apple-touch-icon.png index 57c05cfc9a..c1dcaad2a8 100644 Binary files a/microsite/static/img/apple-touch-icon.png and b/microsite/static/img/apple-touch-icon.png differ diff --git a/microsite/static/img/backstage-logo-cncf.svg b/microsite/static/img/backstage-logo-cncf.svg index b7d10e429c..b5ff591d1b 100644 --- a/microsite/static/img/backstage-logo-cncf.svg +++ b/microsite/static/img/backstage-logo-cncf.svg @@ -1 +1 @@ -05 Logo_Black \ No newline at end of file +05 Logo_Black \ No newline at end of file diff --git a/microsite/static/img/cards-mobile.png b/microsite/static/img/cards-mobile.png index 9cb74e0aff..780da80e3b 100644 Binary files a/microsite/static/img/cards-mobile.png and b/microsite/static/img/cards-mobile.png differ diff --git a/microsite/static/img/cards-wide.png b/microsite/static/img/cards-wide.png index db72cc072b..392bd38382 100644 Binary files a/microsite/static/img/cards-wide.png and b/microsite/static/img/cards-wide.png differ diff --git a/microsite/static/img/cards.png b/microsite/static/img/cards.png index f388dfdb4c..b0f843e29c 100644 Binary files a/microsite/static/img/cards.png and b/microsite/static/img/cards.png differ diff --git a/microsite/static/img/cncf-color.svg b/microsite/static/img/cncf-color.svg index 12f7d3e48a..2b9d5117f9 100644 --- a/microsite/static/img/cncf-color.svg +++ b/microsite/static/img/cncf-color.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/microsite/static/img/cncf-white.svg b/microsite/static/img/cncf-white.svg index d94aaf3249..c5e3b2af9a 100644 --- a/microsite/static/img/cncf-white.svg +++ b/microsite/static/img/cncf-white.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/microsite/static/img/code.gif b/microsite/static/img/code.gif index c0d510f0cc..f5031ed50c 100644 Binary files a/microsite/static/img/code.gif and b/microsite/static/img/code.gif differ diff --git a/microsite/static/img/code.png b/microsite/static/img/code.png index b9d53ee4c5..5e33734b5f 100644 Binary files a/microsite/static/img/code.png and b/microsite/static/img/code.png differ diff --git a/microsite/static/img/compliance.svg b/microsite/static/img/compliance.svg index 7214c5bd9e..b4e72def73 100644 --- a/microsite/static/img/compliance.svg +++ b/microsite/static/img/compliance.svg @@ -1,68 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/components-with-filter-small.png b/microsite/static/img/components-with-filter-small.png index 4cbe2e8371..bf80faaafb 100644 Binary files a/microsite/static/img/components-with-filter-small.png and b/microsite/static/img/components-with-filter-small.png differ diff --git a/microsite/static/img/components-with-filter.png b/microsite/static/img/components-with-filter.png index 294e078cf2..a232699fa4 100644 Binary files a/microsite/static/img/components-with-filter.png and b/microsite/static/img/components-with-filter.png differ diff --git a/microsite/static/img/demo-illustration.svg b/microsite/static/img/demo-illustration.svg index 984b97335b..036912cafa 100644 --- a/microsite/static/img/demo-illustration.svg +++ b/microsite/static/img/demo-illustration.svg @@ -1,66 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/developers.svg b/microsite/static/img/developers.svg index f761828ec7..0c15235213 100644 --- a/microsite/static/img/developers.svg +++ b/microsite/static/img/developers.svg @@ -1,49 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/docs-like-code.png b/microsite/static/img/docs-like-code.png index c9fbd0c466..0913ee1cf4 100644 Binary files a/microsite/static/img/docs-like-code.png and b/microsite/static/img/docs-like-code.png differ diff --git a/microsite/static/img/dot.svg b/microsite/static/img/dot.svg index 93c7f25bdd..7090c946f7 100644 --- a/microsite/static/img/dot.svg +++ b/microsite/static/img/dot.svg @@ -1,6146 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/embraced.svg b/microsite/static/img/embraced.svg index f1c023eea6..e0bacf2a9b 100644 --- a/microsite/static/img/embraced.svg +++ b/microsite/static/img/embraced.svg @@ -1,94 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/favicon.svg b/microsite/static/img/favicon.svg index 535eea5543..8422122718 100644 --- a/microsite/static/img/favicon.svg +++ b/microsite/static/img/favicon.svg @@ -1,17 +1 @@ - - - favicon - - - - - +favicon \ No newline at end of file diff --git a/microsite/static/img/laptop-screen.svg b/microsite/static/img/laptop-screen.svg index a35f880a22..6e63cb50a1 100644 --- a/microsite/static/img/laptop-screen.svg +++ b/microsite/static/img/laptop-screen.svg @@ -1,9 +1 @@ - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/laptop.svg b/microsite/static/img/laptop.svg index c3aad71ce9..5a4b97d216 100644 --- a/microsite/static/img/laptop.svg +++ b/microsite/static/img/laptop.svg @@ -1,45 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/logo-black-248x250.png b/microsite/static/img/logo-black-248x250.png index fdf9a00311..a9cb12ca8e 100644 Binary files a/microsite/static/img/logo-black-248x250.png and b/microsite/static/img/logo-black-248x250.png differ diff --git a/microsite/static/img/logo-gradient-on-dark.svg b/microsite/static/img/logo-gradient-on-dark.svg index 57bb1bdd24..2cd4fad4e5 100644 --- a/microsite/static/img/logo-gradient-on-dark.svg +++ b/microsite/static/img/logo-gradient-on-dark.svg @@ -1,16 +1 @@ - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/logo.svg b/microsite/static/img/logo.svg index b3babaa39a..bee663d69e 100644 --- a/microsite/static/img/logo.svg +++ b/microsite/static/img/logo.svg @@ -1 +1 @@ -03 Logo_Teal \ No newline at end of file +03 Logo_Teal \ No newline at end of file diff --git a/microsite/static/img/logos-background.svg b/microsite/static/img/logos-background.svg index 141b9428c6..610085b9c0 100644 --- a/microsite/static/img/logos-background.svg +++ b/microsite/static/img/logos-background.svg @@ -1,19 +1 @@ - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/logos-signature.svg b/microsite/static/img/logos-signature.svg index 5acbac2a8c..2a10e8f3b4 100644 --- a/microsite/static/img/logos-signature.svg +++ b/microsite/static/img/logos-signature.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/microsite/static/img/logos.svg b/microsite/static/img/logos.svg index d2e0f16c3d..5e9163ee3a 100644 --- a/microsite/static/img/logos.svg +++ b/microsite/static/img/logos.svg @@ -1,142 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/mstile-150x150.png b/microsite/static/img/mstile-150x150.png index 6a63de8a44..713e7eb495 100644 Binary files a/microsite/static/img/mstile-150x150.png and b/microsite/static/img/mstile-150x150.png differ diff --git a/microsite/static/img/open-platform.svg b/microsite/static/img/open-platform.svg index 68b35afbec..6abbc0c7d9 100644 --- a/microsite/static/img/open-platform.svg +++ b/microsite/static/img/open-platform.svg @@ -1,54 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/photo-montage.svg b/microsite/static/img/photo-montage.svg index bb7142b19a..88c1809d2d 100644 --- a/microsite/static/img/photo-montage.svg +++ b/microsite/static/img/photo-montage.svg @@ -1,35 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/plugin-mobile.svg b/microsite/static/img/plugin-mobile.svg index 503e075cea..3d8db71d02 100644 --- a/microsite/static/img/plugin-mobile.svg +++ b/microsite/static/img/plugin-mobile.svg @@ -1,104 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/plugin.svg b/microsite/static/img/plugin.svg index 6ac64e3059..7885f414a7 100644 --- a/microsite/static/img/plugin.svg +++ b/microsite/static/img/plugin.svg @@ -1,108 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/microsite/static/img/screen.gif b/microsite/static/img/screen.gif index ecc82d0875..f790d639cc 100644 Binary files a/microsite/static/img/screen.gif and b/microsite/static/img/screen.gif differ diff --git a/microsite/static/img/spring-boot-service.png b/microsite/static/img/spring-boot-service.png index fccea79822..da0122abb1 100644 Binary files a/microsite/static/img/spring-boot-service.png and b/microsite/static/img/spring-boot-service.png differ diff --git a/microsite/static/img/techdocs-static-mobile.png b/microsite/static/img/techdocs-static-mobile.png index 082518fe21..c827b49a83 100644 Binary files a/microsite/static/img/techdocs-static-mobile.png and b/microsite/static/img/techdocs-static-mobile.png differ diff --git a/microsite/static/img/techdocs-web.png b/microsite/static/img/techdocs-web.png index d1306fb6e7..c460e8eb4d 100644 Binary files a/microsite/static/img/techdocs-web.png and b/microsite/static/img/techdocs-web.png differ diff --git a/microsite/static/img/techdocs.gif b/microsite/static/img/techdocs.gif index 1f0b2b034d..b133caf058 100644 Binary files a/microsite/static/img/techdocs.gif and b/microsite/static/img/techdocs.gif differ diff --git a/microsite/static/img/techdocs.png b/microsite/static/img/techdocs.png index a726012b68..f789745582 100644 Binary files a/microsite/static/img/techdocs.png and b/microsite/static/img/techdocs.png differ diff --git a/microsite/static/img/techdocs2.gif b/microsite/static/img/techdocs2.gif index ba937581b3..201f192330 100644 Binary files a/microsite/static/img/techdocs2.gif and b/microsite/static/img/techdocs2.gif differ diff --git a/microsite/static/img/techs.png b/microsite/static/img/techs.png index 20026ccc92..46dfad36db 100644 Binary files a/microsite/static/img/techs.png and b/microsite/static/img/techs.png differ diff --git a/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_05 Logo_Black.jpg b/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_05 Logo_Black.jpg index cb0b6c7959..93b69e963c 100644 Binary files a/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_05 Logo_Black.jpg and b/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_05 Logo_Black.jpg differ diff --git a/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.jpg b/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.jpg index cbd97e78ea..76f691ba5e 100644 Binary files a/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.jpg and b/microsite/static/logo_assets/jpeg/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.jpg differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_01_Logo_White.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_01_Logo_White.png index 84a1dba855..02db1a275c 100644 Binary files a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_01_Logo_White.png and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_01_Logo_White.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_02_Icon_White.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_02_Icon_White.png index 29264f8ac0..0820c6ae50 100644 Binary files a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_02_Icon_White.png and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_02_Icon_White.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_03_Logo_Teal.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_03_Logo_Teal.png index 092599ce63..b75137ed31 100644 Binary files a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_03_Logo_Teal.png and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_03_Logo_Teal.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png index d662301101..a9c69443ff 100644 Binary files a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_04_Icon_Teal.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_05_Logo_Black.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_05_Logo_Black.png index 7da1808784..dfc99e57ed 100644 Binary files a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_05_Logo_Black.png and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_05_Logo_Black.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.png index 5049d54513..b33b97254c 100644 Binary files a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.png and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_06_Icon_Black.png differ diff --git a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_07_Large_Icon_Gradient.png b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_07_Large_Icon_Gradient.png index 889017fbfb..8a21bc14ae 100644 Binary files a/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_07_Large_Icon_Gradient.png and b/microsite/static/logo_assets/png/Backstage_Identity_Assets_Artwork_RGB_07_Large_Icon_Gradient.png differ diff --git a/microsite/static/logo_assets/svg/Icon_Black.svg b/microsite/static/logo_assets/svg/Icon_Black.svg index 2eb2dda646..bcd35e6648 100644 --- a/microsite/static/logo_assets/svg/Icon_Black.svg +++ b/microsite/static/logo_assets/svg/Icon_Black.svg @@ -1 +1 @@ -06 Icon_Black \ No newline at end of file +06 Icon_Black \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Icon_Gradient.svg b/microsite/static/logo_assets/svg/Icon_Gradient.svg index bbdb4bba27..2f361046e7 100644 --- a/microsite/static/logo_assets/svg/Icon_Gradient.svg +++ b/microsite/static/logo_assets/svg/Icon_Gradient.svg @@ -1 +1 @@ -07 Large Icon_Gradient \ No newline at end of file +07 Large Icon_Gradient \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Icon_Teal.svg b/microsite/static/logo_assets/svg/Icon_Teal.svg index 7152749073..eaad1cf2d3 100644 --- a/microsite/static/logo_assets/svg/Icon_Teal.svg +++ b/microsite/static/logo_assets/svg/Icon_Teal.svg @@ -1 +1 @@ -04 Icon_Teal \ No newline at end of file +04 Icon_Teal \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Icon_White.svg b/microsite/static/logo_assets/svg/Icon_White.svg index c1d6f388b4..84e3c546a5 100644 --- a/microsite/static/logo_assets/svg/Icon_White.svg +++ b/microsite/static/logo_assets/svg/Icon_White.svg @@ -1 +1 @@ -02 Icon_White \ No newline at end of file +02 Icon_White \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Logo_Black.svg b/microsite/static/logo_assets/svg/Logo_Black.svg index b7d10e429c..b5ff591d1b 100644 --- a/microsite/static/logo_assets/svg/Logo_Black.svg +++ b/microsite/static/logo_assets/svg/Logo_Black.svg @@ -1 +1 @@ -05 Logo_Black \ No newline at end of file +05 Logo_Black \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Logo_Teal.svg b/microsite/static/logo_assets/svg/Logo_Teal.svg index b3babaa39a..bee663d69e 100644 --- a/microsite/static/logo_assets/svg/Logo_Teal.svg +++ b/microsite/static/logo_assets/svg/Logo_Teal.svg @@ -1 +1 @@ -03 Logo_Teal \ No newline at end of file +03 Logo_Teal \ No newline at end of file diff --git a/microsite/static/logo_assets/svg/Logo_White.svg b/microsite/static/logo_assets/svg/Logo_White.svg index 7b9c543951..f09a8fdc6e 100644 --- a/microsite/static/logo_assets/svg/Logo_White.svg +++ b/microsite/static/logo_assets/svg/Logo_White.svg @@ -1 +1 @@ -01 Logo_White \ No newline at end of file +01 Logo_White \ No newline at end of file diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 84b5d50d60..40e46c3545 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -959,6 +959,11 @@ 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== + "@types/cheerio@^0.22.8": version "0.22.21" resolved "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.21.tgz#5e37887de309ba11b2e19a6e14cad7874b31a8a3" @@ -2228,10 +2233,10 @@ dir-glob@2.0.0: arrify "^1.0.1" path-type "^3.0.0" -docusaurus@^2.0.0-alpha.64: - version "2.0.0-alpha.64" - resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.64.tgz#7833960e9d338403894a27b79058aa4076a676d3" - integrity sha512-ARCx0GwAvc5qx7AHvRVZidZuoDTfaaGXzgmkU23NahU6jzO/aK2Q1bH8IKNEQ5C2JuDerQ/hHDh80N20ijk82g== +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== dependencies: "@babel/core" "^7.9.0" "@babel/plugin-proposal-class-properties" "^7.8.3" @@ -5199,6 +5204,11 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= +prettier@^2.0.5: + version "2.1.2" + resolved "https://registry.npmjs.org/prettier/-/prettier-2.1.2.tgz#3050700dae2e4c8b67c4c3f666cdb8af405e1ce5" + integrity sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg== + prismjs@^1.17.1: version "1.21.0" resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.21.0.tgz#36c086ec36b45319ec4218ee164c110f9fc015a3" diff --git a/mkdocs.yml b/mkdocs.yml index ba607f8a6e..4fbec4650d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,7 +5,6 @@ nav: - Overview: - What is Backstage?: 'overview/what-is-backstage.md' - Backstage architecture: 'overview/architecture-overview.md' - - Architecture and terminology: 'overview/architecture-terminology.md' - Roadmap: 'overview/roadmap.md' - Vision: 'overview/vision.md' - The Spotify story: 'overview/background.md' @@ -32,6 +31,7 @@ nav: - Configuration: 'features/software-catalog/configuration.md' - System model: 'features/software-catalog/system-model.md' - YAML File Format: 'features/software-catalog/descriptor-format.md' + - Entity References: 'features/software-catalog/references.md' - Well-known Annotations: 'features/software-catalog/well-known-annotations.md' - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' @@ -51,6 +51,7 @@ nav: - Concepts: 'features/techdocs/concepts.md' - TechDocs Architecture: 'features/techdocs/architecture.md' - Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md' + - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' - Plugins: - Overview: 'plugins/index.md' @@ -104,6 +105,7 @@ nav: - ADR006 - Avoid React.FC and React.SFC: 'architecture-decisions/adr006-avoid-react-fc.md' - ADR007 - Use MSW for Network Request Mocking: 'architecture-decisions/adr007-use-msw-to-mock-service-requests.md' - 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' - FAQ: FAQ.md diff --git a/package.json b/package.json index c64b7f6951..b9c84a4d90 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "root", "private": true, "engines": { - "node": "12" + "node": "12 || 14" }, "scripts": { "dev": "concurrently \"yarn start\" \"yarn start-backend\"", @@ -16,12 +16,13 @@ "test": "lerna run test --since origin/master -- --coverage", "test:all": "lerna run test -- --coverage", "lint": "lerna run lint --since origin/master --", + "lint:docs": "node ./scripts/check-docs-quality", "lint:all": "lerna run lint --", "lint:type-deps": "node scripts/check-type-dependencies.js", "docgen": "lerna run docgen", "docker-build:app": "yarn workspace example-app build && docker build . -t spotify/backstage", "docker-build": "yarn tsc && yarn workspace example-backend build-image", - "create-plugin": "backstage-cli create-plugin", + "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", "prettier:check": "prettier --check .", @@ -37,6 +38,7 @@ }, "version": "1.0.0", "devDependencies": { + "@changesets/cli": "2.10.2", "@spotify/eslint-config-oss": "^1.0.1", "@spotify/prettier-config": "^8.0.0", "concurrently": "^5.2.0", @@ -45,7 +47,8 @@ "lerna": "^3.20.2", "lint-staged": "^10.1.0", "prettier": "^2.0.5", - "recursive-readdir": "^2.2.2" + "recursive-readdir": "^2.2.2", + "shx": "^0.3.2" }, "husky": { "hooks": { @@ -60,6 +63,9 @@ ], "*.{json,md}": [ "prettier --write" + ], + "*.md": [ + "vale" ] }, "jest": { diff --git a/packages/app/package.json b/packages/app/package.json index cb61e1ed8c..20b9e24701 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,37 +1,40 @@ { "name": "example-app", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.22", - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/core": "^0.1.1-alpha.22", - "@backstage/plugin-api-docs": "^0.1.1-alpha.22", - "@backstage/plugin-catalog": "^0.1.1-alpha.22", - "@backstage/plugin-circleci": "^0.1.1-alpha.22", - "@backstage/plugin-explore": "^0.1.1-alpha.22", - "@backstage/plugin-gcp-projects": "^0.1.1-alpha.22", - "@backstage/plugin-github-actions": "^0.1.1-alpha.22", - "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.22", - "@backstage/plugin-graphiql": "^0.1.1-alpha.22", - "@backstage/plugin-jenkins": "^0.1.1-alpha.22", - "@backstage/plugin-lighthouse": "^0.1.1-alpha.22", - "@backstage/plugin-newrelic": "^0.1.1-alpha.22", - "@backstage/plugin-register-component": "^0.1.1-alpha.22", - "@backstage/plugin-rollbar": "^0.1.1-alpha.22", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.22", - "@backstage/plugin-sentry": "^0.1.1-alpha.22", - "@backstage/plugin-tech-radar": "^0.1.1-alpha.22", - "@backstage/plugin-techdocs": "^0.1.1-alpha.22", - "@backstage/plugin-welcome": "^0.1.1-alpha.22", - "@backstage/test-utils": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@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", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.0", - "@roadiehq/backstage-plugin-github-pull-requests": "0.3.0", - "@roadiehq/backstage-plugin-travis-ci": "^0.1.4", + "@roadiehq/backstage-plugin-github-pull-requests": "^0.4.3", + "@roadiehq/backstage-plugin-travis-ci": "^0.2.3", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", diff --git a/packages/app/public/android-chrome-192x192.png b/packages/app/public/android-chrome-192x192.png index 4660f988c1..eec0ae25b9 100644 Binary files a/packages/app/public/android-chrome-192x192.png and b/packages/app/public/android-chrome-192x192.png differ diff --git a/packages/app/public/android-chrome-512x512.png b/packages/app/public/android-chrome-512x512.png index d9b0a6ba78..ac0578133b 100644 Binary files a/packages/app/public/android-chrome-512x512.png and b/packages/app/public/android-chrome-512x512.png differ diff --git a/packages/app/public/apple-touch-icon.png b/packages/app/public/apple-touch-icon.png index 57c05cfc9a..6a45afb38f 100644 Binary files a/packages/app/public/apple-touch-icon.png and b/packages/app/public/apple-touch-icon.png differ diff --git a/packages/app/public/favicon.svg b/packages/app/public/favicon.svg index 351dcc8809..7f2d375cd8 100644 --- a/packages/app/public/favicon.svg +++ b/packages/app/public/favicon.svg @@ -1,17 +1 @@ - - - Backstage favicon - - - - - +Backstage favicon \ No newline at end of file diff --git a/packages/app/public/safari-pinned-tab.svg b/packages/app/public/safari-pinned-tab.svg index 3b0f666390..0f500b3002 100644 --- a/packages/app/public/safari-pinned-tab.svg +++ b/packages/app/public/safari-pinned-tab.svg @@ -1,28 +1 @@ - - - - -Created by potrace 1.11, written by Peter Selinger 2001-2013 - - - - - - +Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index cddd093855..40c49416bf 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -31,7 +31,7 @@ describe('App', () => { baseUrl: 'http://localhost:3003', }, techdocs: { - storageUrl: 'http://localhost:7000/techdocs/static/docs', + storageUrl: 'http://localhost:7000/api/techdocs/static/docs', }, }, context: 'test', diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 7f57f6f970..c129bebfa2 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -16,11 +16,8 @@ import { errorApiRef, - discoveryApiRef, - UrlPatternDiscovery, githubAuthApiRef, createApiFactory, - configApiRef, } from '@backstage/core'; import { @@ -32,21 +29,16 @@ 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'; + export const apis = [ - // TODO(Rugvip): migrate to use /api - createApiFactory({ - api: discoveryApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => - UrlPatternDiscovery.compile( - `${configApi.getString('backend.baseUrl')}/{{ pluginId }}`, - ), - }), createApiFactory({ api: graphQlBrowseApiRef, deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef }, @@ -66,6 +58,8 @@ 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 03e24cf4ae..45ffca7801 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -23,6 +23,7 @@ import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import MoneyIcon from '@material-ui/icons/MonetizationOn'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; import { @@ -94,6 +95,7 @@ const Root: FC<{}> = ({ children }) => ( + { // This component is just an example of how you can implement your company's logic in entity page. @@ -49,26 +60,61 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => { return ; case isCircleCIAvailable(entity): return ; + case isCloudbuildAvailable(entity): + return ; + case isTravisCIAvailable(entity): + return ; default: return ( - - No CI/CD is available for this entity. Check corresponding - annotations! - + + Read more + + } + /> ); } }; +const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => { + let content: ReactNode; + switch (true) { + case isJenkinsAvailable(entity): + content = ; + break; + case isGitHubActionsAvailable(entity): + content = ; + break; + case isTravisCIAvailable(entity): + content = ; + break; + default: + content = null; + } + if (!content) { + return null; + } + return ( + + {content} + + ); +}; + const OverviewContent = ({ entity }: { entity: Entity }) => ( - + - {isJenkinsAvailable(entity) && ( - - - - )} + ); @@ -99,6 +145,11 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( title="Docs" element={} /> + } + /> ); @@ -124,6 +175,11 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( title="Docs" element={} /> + } + /> ); const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index fab0e92577..388bc8d8b6 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -19,6 +19,7 @@ import { gitlabAuthApiRef, oktaAuthApiRef, githubAuthApiRef, + samlAuthApiRef, microsoftAuthApiRef, } from '@backstage/core'; @@ -53,4 +54,10 @@ export const providers = [ message: 'Sign In using Okta', apiRef: oktaAuthApiRef, }, + { + id: 'saml-auth-provider', + title: 'SAML', + message: 'Sign In using SAML', + apiRef: samlAuthApiRef, + }, ]; diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index b36d0323e0..85c4977d67 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -33,3 +33,6 @@ export { plugin as Jenkins } from '@backstage/plugin-jenkins'; export { plugin as ApiDocs } from '@backstage/plugin-api-docs'; export { plugin as GithubPullRequests } from '@roadiehq/backstage-plugin-github-pull-requests'; 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'; diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts new file mode 100644 index 0000000000..c01092dc51 --- /dev/null +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -0,0 +1,209 @@ +/* + * 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/app/src/plugins/cost-insights/index.ts b/packages/app/src/plugins/cost-insights/index.ts new file mode 100644 index 0000000000..47d540049f --- /dev/null +++ b/packages/app/src/plugins/cost-insights/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 { ExampleCostInsightsClient } from './ExampleCostInsightsClient'; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 9d8a7061e0..1c888ab8fe 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.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.1-alpha.22", - "@backstage/config": "^0.1.1-alpha.22", - "@backstage/config-loader": "^0.1.1-alpha.22", + "@backstage/cli-common": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", + "@backstage/config-loader": "^0.1.1-alpha.24", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "compression": "^1.7.4", @@ -39,11 +39,13 @@ "express": "^4.17.1", "express-prom-bundle": "^6.1.0", "express-promise-router": "^3.0.3", + "git-url-parse": "^11.2.0", "helmet": "^4.0.0", "knex": "^0.21.1", "lodash": "^4.17.15", "logform": "^2.1.1", "morgan": "^1.10.0", + "node-fetch": "^2.6.0", "prom-client": "^12.0.0", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", @@ -58,10 +60,11 @@ } }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", "@types/compression": "^1.7.0", "@types/http-errors": "^1.6.3", "@types/morgan": "^1.9.0", + "@types/node-fetch": "^2.5.7", "@types/stoppable": "^1.1.0", "@types/supertest": "^2.0.8", "@types/webpack-env": "^1.15.2", @@ -70,6 +73,7 @@ "http-errors": "^1.7.3", "jest": "^26.0.1", "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", "supertest": "^4.0.2" }, "files": [ diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 6f7500bb86..247eb593cc 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -23,7 +23,7 @@ import { loadConfig } from '@backstage/config-loader'; export async function loadBackendConfig() { const paths = findPaths(__dirname); const configs = await loadConfig({ - env: process.env.NODE_ENV, + env: process.env.NODE_ENV ?? 'development', rootPaths: [paths.targetRoot, paths.targetDir], shouldReadSecrets: true, }); diff --git a/packages/backend-common/src/database/connection.ts b/packages/backend-common/src/database/connection.ts index 009a85239f..4cd8606fc0 100644 --- a/packages/backend-common/src/database/connection.ts +++ b/packages/backend-common/src/database/connection.ts @@ -17,7 +17,7 @@ import knex from 'knex'; import { Config } from '@backstage/config'; import { mergeDatabaseConfig } from './config'; -import { createPgDatabaseClient } from './postgres'; +import { createPgDatabaseClient, ensurePgDatabaseExists } from './postgres'; import { createSqliteDatabaseClient } from './sqlite3'; type DatabaseClient = 'pg' | 'sqlite3' | string; @@ -48,3 +48,19 @@ export function createDatabaseClient( * @deprecated Use createDatabaseClient instead */ export const createDatabase = createDatabaseClient; + +/** + * Ensures that the given databases all exist, creating them if they do not. + */ +export async function ensureDatabaseExists( + dbConfig: Config, + ...databases: Array +) { + const client: DatabaseClient = dbConfig.getString('client'); + + if (client === 'pg') { + return ensurePgDatabaseExists(dbConfig, ...databases); + } + + return undefined; +} diff --git a/packages/backend-common/src/database/postgres.ts b/packages/backend-common/src/database/postgres.ts index 03a74156c7..82e971f081 100644 --- a/packages/backend-common/src/database/postgres.ts +++ b/packages/backend-common/src/database/postgres.ts @@ -95,3 +95,39 @@ function requirePgConnectionString() { throw new Error(`${message}\n${e.message}`); } } + +/** + * Creates the missing Postgres database if it does not exist + * + * @param dbConfig The database config + * @param databases The name of the databases to create + */ +export async function ensurePgDatabaseExists( + dbConfig: Config, + ...databases: Array +) { + const admin = createPgDatabaseClient(dbConfig, { + connection: { + database: 'postgres', + }, + }); + + try { + const ensureDatabase = async (database: string) => { + const result = await admin + .from('pg_database') + .where('datname', database) + .count>(); + + if (parseInt(result[0].count, 10) > 0) { + return; + } + + await admin.raw(`CREATE DATABASE ??`, [database]); + }; + + await Promise.all(databases.map(ensureDatabase)); + } finally { + await admin.destroy(); + } +} diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts new file mode 100644 index 0000000000..184746b924 --- /dev/null +++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts @@ -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 { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from './types'; +import { readBaseOptions } from '../service/lib/config'; +import { DEFAULT_PORT } from '../service/lib/ServiceBuilderImpl'; + +/** + * SingleHostDiscovery is a basic PluginEndpointDiscovery implementation + * that assumes that all plugins are hosted in a single deployment. + * + * The deployment may be scaled horizontally, as long as the external URL + * is the same for all instances. However, internal URLs will always be + * resolved to the same host, so there won't be any balancing of internal traffic. + */ +export class SingleHostDiscovery implements PluginEndpointDiscovery { + /** + * Creates a new SingleHostDiscovery discovery instance by reading + * from the `backend` config section, specifically the `.baseUrl` for + * discovering the external URL, and the `.listen` and `.https` config + * for the internal one. + * + * The basePath defaults to `/api`, meaning the default full internal + * path for the `catalog` plugin will be `http://localhost:7000/api/catalog`. + */ + static fromConfig(config: Config, options?: { basePath?: string }) { + const basePath = options?.basePath ?? '/api'; + const externalBaseUrl = config.getString('backend.baseUrl'); + + const { listenHost = '::', listenPort = DEFAULT_PORT } = readBaseOptions( + config.getConfig('backend'), + ); + const protocol = config.has('backend.https') ? 'https' : 'http'; + + // Translate bind-all to localhost, and support IPv6 + let host = listenHost; + if (host === '::') { + // We use localhost instead of ::1, since IPv6-compatible systems should default + // to using IPv6 when they see localhost, but if the system doesn't support IPv6 + // things will still work. + host = 'localhost'; + } else if (host === '0.0.0.0') { + host = '127.0.0.1'; + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const internalBaseUrl = `${protocol}://${host}:${listenPort}`; + + return new SingleHostDiscovery( + internalBaseUrl + basePath, + externalBaseUrl + basePath, + ); + } + + private constructor( + private readonly internalBaseUrl: string, + private readonly externalBaseUrl: string, + ) {} + + async getBaseUrl(pluginId: string): Promise { + return `${this.internalBaseUrl}/${pluginId}`; + } + + async getExternalBaseUrl(pluginId: string): Promise { + return `${this.externalBaseUrl}/${pluginId}`; + } +} diff --git a/packages/backend-common/src/discovery/index.ts b/packages/backend-common/src/discovery/index.ts new file mode 100644 index 0000000000..7fe320c1e5 --- /dev/null +++ b/packages/backend-common/src/discovery/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 { SingleHostDiscovery } from './SingleHostDiscovery'; +export type { PluginEndpointDiscovery } from './types'; diff --git a/packages/backend-common/src/discovery/types.ts b/packages/backend-common/src/discovery/types.ts new file mode 100644 index 0000000000..22eb4b23d4 --- /dev/null +++ b/packages/backend-common/src/discovery/types.ts @@ -0,0 +1,61 @@ +/* + * 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. + */ + +/** + * The PluginEndpointDiscovery is used to provide a mechanism for backend + * plugins to discover the endpoints for itself or other backend plugins. + * + * The purpose of the discovery API is to allow for many different deployment + * setups and routing methods through a central configuration, instead + * of letting each individual plugin manage that configuration. + * + * Implementations of the discovery API can be as simple as a URL pattern + * using the pluginId, but could also have overrides for individual plugins, + * or query a separate discovery service. + */ +export type PluginEndpointDiscovery = { + /** + * Returns the internal HTTP base URL for a given plugin, without a trailing slash. + * + * The returned URL should point to an internal endpoint for the plugin, with + * the shortest route possible. The URL should be used for service-to-service + * communication within a Backstage backend deployment. + * + * This method must always be called just before making a request, as opposed to + * fetching the URL when constructing an API client. That is to ensure that more + * flexible routing patterns can be supported. + * + * For example, asking for the URL for `catalog` may return something + * like `http://10.1.2.3/api/catalog` + */ + getBaseUrl(pluginId: string): Promise; + + /** + * Returns the external HTTP base backend URL for a given plugin, without a trailing slash. + * + * The returned URL should point to an external endpoint for the plugin, such that + * it is reachable from the Backstage frontend and other external services. The returned + * URL should be usable for example as a callback / webhook URL. + * + * The returned URL should be stable and in general not change unless other static + * or external configuration is changed. Changes should not come as a surprise + * to an operator of the Backstage backend. + * + * For example, asking for the URL for `catalog` may return something + * like `https://backstage.example.com/api/catalog` + */ + getExternalBaseUrl(pluginId: string): Promise; +}; diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index c527f5fea1..e968edef69 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -16,9 +16,11 @@ export * from './config'; export * from './database'; +export * from './discovery'; export * from './errors'; export * from './logging'; export * from './middleware'; +export * from './reading'; export * from './service'; export * from './paths'; export * from './hot'; diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts new file mode 100644 index 0000000000..c9e1fc5bc7 --- /dev/null +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -0,0 +1,124 @@ +/* + * 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '../logging'; +import { AzureUrlReader } from './AzureUrlReader'; + +const logger = getVoidLogger(); + +describe('AzureUrlReader', () => { + const worker = setupServer(); + + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); + afterAll(() => worker.close()); + + beforeEach(() => { + worker.use( + rest.get('*', (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + url: req.url.toString(), + headers: req.headers.getAllHeaders(), + }), + ), + ), + ); + }); + afterEach(() => worker.resetHandlers()); + + const createConfig = (token?: string) => + new ConfigReader( + { + integrations: { azure: [{ host: 'dev.azure.com', token }] }, + }, + 'test-config', + ); + + it.each([ + { + url: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', + config: createConfig(), + response: expect.objectContaining({ + url: + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + }), + }, + { + url: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', + config: createConfig(), + response: expect.objectContaining({ + url: + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + }), + }, + { + url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml', + config: createConfig('0123456789'), + response: expect.objectContaining({ + headers: expect.objectContaining({ + authorization: 'Basic OjAxMjM0NTY3ODk=', + }), + }), + }, + { + url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml', + config: createConfig(undefined), + response: expect.objectContaining({ + headers: expect.not.objectContaining({ + authorization: expect.anything(), + }), + }), + }, + ])('should handle happy path %#', async ({ url, config, response }) => { + const [{ reader }] = AzureUrlReader.factory({ config, logger }); + + const data = await reader.read(url); + const res = await JSON.parse(data.toString('utf-8')); + expect(res).toEqual(response); + }); + + 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 Azure Devops 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.azure[0].token' in 'test-config', got empty-string, wanted string", + }, + ])('should handle error path %#', async ({ url, config, error }) => { + await expect(async () => { + const [{ reader }] = AzureUrlReader.factory({ config, logger }); + 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 new file mode 100644 index 0000000000..28fbf25eea --- /dev/null +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -0,0 +1,165 @@ +/* + * 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, { RequestInit, HeadersInit, Response } from 'node-fetch'; +import { Config } from '@backstage/config'; +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; +} + +export class AzureUrlReader implements UrlReader { + static factory: ReaderFactory = ({ config }) => { + return readConfig(config).map(options => { + const reader = new AzureUrlReader(options); + const predicate = (url: URL) => url.host === options.host; + return { reader, predicate }; + }); + }; + + constructor(private readonly options: Options) { + if (options.host !== 'dev.azure.com') { + throw Error( + `Azure integration currently only supports 'dev.azure.com', tried to use host '${options.host}'`, + ); + } + } + + async read(url: string): Promise { + const builtUrl = this.buildRawUrl(url); + + let response: Response; + try { + response = await fetch(builtUrl.toString(), this.getRequestOptions()); + } catch (e) { + throw new Error(`Unable to read ${url}, ${e}`); + } + + // 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(); + } + + const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + // 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} + private buildRawUrl(target: string): URL { + try { + const url = new URL(target); + + const [ + empty, + userOrOrg, + project, + srcKeyword, + repoName, + ] = url.pathname.split('/'); + + const path = url.searchParams.get('path') || ''; + const ref = url.searchParams.get('version')?.substr(2); + + if ( + url.hostname !== 'dev.azure.com' || + empty !== '' || + userOrOrg === '' || + project === '' || + srcKeyword !== '_git' || + repoName === '' || + path === '' || + ref === '' + ) { + throw new Error('Wrong Azure Devops URL or Invalid file path'); + } + + // transform to api + url.pathname = [ + empty, + userOrOrg, + project, + '_apis', + 'git', + 'repositories', + repoName, + 'items', + ].join('/'); + + const queryParams = [`path=${path}`]; + + if (ref) { + queryParams.push(`version=${ref}`); + } + + url.search = queryParams.join('&'); + + url.protocol = 'https'; + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } + } + + private getRequestOptions(): RequestInit { + const headers: HeadersInit = {}; + + if (this.options.token) { + headers.Authorization = `Basic ${Buffer.from( + `:${this.options.token}`, + 'utf8', + ).toString('base64')}`; + } + + return { headers }; + } + + toString() { + const { host, token } = this.options; + return `azure{host=${host},authed=${Boolean(token)}}`; + } +} diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts new file mode 100644 index 0000000000..c3e61fb821 --- /dev/null +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -0,0 +1,153 @@ +/* + * 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '../logging'; +import { BitbucketUrlReader } from './BitbucketUrlReader'; + +const logger = getVoidLogger(); + +describe('BitbucketUrlReader', () => { + const worker = setupServer(); + + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); + afterAll(() => worker.close()); + + beforeEach(() => { + worker.use( + rest.get('*', (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + url: req.url.toString(), + headers: req.headers.getAllHeaders(), + }), + ), + ), + ); + }); + 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: + '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.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); + }); +}); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts new file mode 100644 index 0000000000..e2576eed47 --- /dev/null +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -0,0 +1,162 @@ +/* + * 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, { RequestInit, HeadersInit, Response } from 'node-fetch'; +import { Config } from '@backstage/config'; +import { ReaderFactory, UrlReader } from './types'; +import { NotFoundError } from '../errors'; + +type Options = { + // TODO: added here for future support, but we only allow bitbucket.org for now + host: string; + auth?: { + username: string; + appPassword: string; + }; +}; + +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; +} + +export class BitbucketUrlReader implements UrlReader { + static factory: ReaderFactory = ({ config }) => { + return readConfig(config).map(options => { + const reader = new BitbucketUrlReader(options); + const predicate = (url: URL) => url.host === options.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}'`, + ); + } + } + + async read(url: string): Promise { + const builtUrl = this.buildRawUrl(url); + + let response: Response; + try { + response = await fetch(builtUrl.toString(), this.getRequestOptions()); + } catch (e) { + throw new Error(`Unable to read ${url}, ${e}`); + } + + if (response.ok) { + return response.buffer(); + } + + const message = `${url} could not be read as ${builtUrl}, ${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, + }; + } + + toString() { + const { host, auth } = this.options; + return `bitbucket{host=${host},authed=${Boolean(auth)}}`; + } +} diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts new file mode 100644 index 0000000000..ea56f2182d --- /dev/null +++ b/packages/backend-common/src/reading/FetchUrlReader.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 fetch, { Response } from 'node-fetch'; +import { NotFoundError } from '../errors'; +import { UrlReader } from './types'; + +/** + * A UrlReader that does a plain fetch of the URL. + */ +export class FetchUrlReader implements UrlReader { + async read(url: string): Promise { + let response: Response; + try { + response = await fetch(url); + } catch (e) { + throw new Error(`Unable to read ${url}, ${e}`); + } + + if (response.ok) { + return response.buffer(); + } + + const message = `could not read ${url}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + toString() { + return 'fetch{}'; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts similarity index 66% rename from plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.test.ts rename to packages/backend-common/src/reading/GithubUrlReader.test.ts index 3ab24541e7..8df464db9f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -14,23 +14,21 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; -import { LocationSpec } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import { getApiRequestOptions, getApiUrl, getRawRequestOptions, getRawUrl, - GithubReaderProcessor, + GithubUrlReader, ProviderConfig, readConfig, -} from './GithubReaderProcessor'; +} from './GithubUrlReader'; -describe('GithubReaderProcessor', () => { +describe('GithubUrlReader', () => { describe('getApiRequestOptions', () => { it('sets the correct API version', () => { - const config: ProviderConfig = { target: '', apiBaseUrl: '' }; + const config: ProviderConfig = { host: '', apiBaseUrl: '' }; expect((getApiRequestOptions(config).headers as any).Accept).toEqual( 'application/vnd.github.v3.raw', ); @@ -38,12 +36,12 @@ describe('GithubReaderProcessor', () => { it('inserts a token when needed', () => { const withToken: ProviderConfig = { - target: '', + host: '', apiBaseUrl: '', token: 'A', }; const withoutToken: ProviderConfig = { - target: '', + host: '', apiBaseUrl: '', }; expect( @@ -58,12 +56,12 @@ describe('GithubReaderProcessor', () => { describe('getRawRequestOptions', () => { it('inserts a token when needed', () => { const withToken: ProviderConfig = { - target: '', + host: '', rawBaseUrl: '', token: 'A', }; const withoutToken: ProviderConfig = { - target: '', + host: '', rawBaseUrl: '', }; expect( @@ -77,13 +75,13 @@ describe('GithubReaderProcessor', () => { describe('getApiUrl', () => { it('rejects targets that do not look like URLs', () => { - const config: ProviderConfig = { target: '', apiBaseUrl: '' }; + const config: ProviderConfig = { host: '', apiBaseUrl: '' }; expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); }); it('happy path for github', () => { const config: ProviderConfig = { - target: 'https://github.com', + host: 'github.com', apiBaseUrl: 'https://api.github.com', }; expect( @@ -110,7 +108,7 @@ describe('GithubReaderProcessor', () => { it('happy path for ghe', () => { const config: ProviderConfig = { - target: 'https://ghe.mycompany.net', + host: 'ghe.mycompany.net', apiBaseUrl: 'https://ghe.mycompany.net/api/v3', }; expect( @@ -128,13 +126,13 @@ describe('GithubReaderProcessor', () => { describe('getRawUrl', () => { it('rejects targets that do not look like URLs', () => { - const config: ProviderConfig = { target: '', apiBaseUrl: '' }; + const config: ProviderConfig = { host: '', apiBaseUrl: '' }; expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); }); it('happy path for github', () => { const config: ProviderConfig = { - target: 'https://github.com', + host: 'github.com', rawBaseUrl: 'https://raw.githubusercontent.com', }; expect( @@ -151,7 +149,7 @@ describe('GithubReaderProcessor', () => { it('happy path for ghe', () => { const config: ProviderConfig = { - target: 'https://ghe.mycompany.net', + host: 'ghe.mycompany.net', rawBaseUrl: 'https://ghe.mycompany.net/raw', }; expect( @@ -167,23 +165,23 @@ describe('GithubReaderProcessor', () => { describe('readConfig', () => { function config( - providers: { target: string; apiBaseUrl?: string; token?: string }[], + providers: { host: string; apiBaseUrl?: string; token?: string }[], ) { return ConfigReader.fromConfigs([ { context: '', data: { - catalog: { processors: { github: { providers } } }, + integrations: { github: providers }, }, }, ]); } it('adds a default GitHub entry when missing', () => { - const output = readConfig(config([]), getVoidLogger()); + const output = readConfig(config([])); expect(output).toEqual([ { - target: 'https://github.com', + host: 'github.com', apiBaseUrl: 'https://api.github.com', rawBaseUrl: 'https://raw.githubusercontent.com', }, @@ -191,13 +189,10 @@ describe('GithubReaderProcessor', () => { }); it('injects the correct GitHub API base URL when missing', () => { - const output = readConfig( - config([{ target: 'https://github.com' }]), - getVoidLogger(), - ); + const output = readConfig(config([{ host: 'github.com' }])); expect(output).toEqual([ { - target: 'https://github.com', + host: 'github.com', apiBaseUrl: 'https://api.github.com', rawBaseUrl: 'https://raw.githubusercontent.com', }, @@ -205,64 +200,33 @@ describe('GithubReaderProcessor', () => { }); it('rejects custom targets with no base URLs', () => { - expect(() => - readConfig( - config([{ target: 'https://ghe.company.com' }]), - getVoidLogger(), - ), - ).toThrow( - 'Provider at https://ghe.company.com must configure an explicit apiBaseUrl or rawBaseUrl', + 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([{ target: 7 } as any]), getVoidLogger()), - ).toThrow(/target/); - expect(() => - readConfig(config([{ noTarget: '7' } as any]), getVoidLogger()), - ).toThrow(/target/); - expect(() => - readConfig( - config([{ target: 'https://github.com', apiBaseUrl: 7 } as any]), - getVoidLogger(), - ), + readConfig(config([{ host: 'github.com', apiBaseUrl: 7 } as any])), ).toThrow(/apiBaseUrl/); expect(() => - readConfig( - config([{ target: 'https://github.com', token: 7 } as any]), - getVoidLogger(), - ), + readConfig(config([{ host: 'github.com', token: 7 } as any])), ).toThrow(/token/); }); }); describe('implementation', () => { - it('rejects unknown types', async () => { - const processor = new GithubReaderProcessor([ - { target: 'https://github.com', apiBaseUrl: 'https://api.github.com' }, - ]); - const location: LocationSpec = { - type: 'not-github/api', - target: 'https://github.com', - }; - await expect( - processor.readLocation(location, false, () => {}), - ).resolves.toBeFalsy(); - }); - it('rejects unknown targets', async () => { - const processor = new GithubReaderProcessor([ - { target: 'https://github.com', apiBaseUrl: 'https://api.github.com' }, - ]); - const location: LocationSpec = { - type: 'github/api', - target: 'https://not.github.com/apa', - }; + const processor = new GithubUrlReader({ + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }); await expect( - processor.readLocation(location, false, () => {}), + processor.read('https://not.github.com/apa'), ).rejects.toThrow( - /There is no GitHub provider that matches https:\/\/not.github.com\/apa/, + 'Incorrect URL: https://not.github.com/apa, Error: Invalid GitHub URL or file path', ); }); }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts new file mode 100644 index 0000000000..e5bed6dd26 --- /dev/null +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -0,0 +1,236 @@ +/* + * 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 } from '@backstage/config'; +import parseGitUri from 'git-url-parse'; +import fetch, { HeadersInit, RequestInit, Response } from 'node-fetch'; +import { NotFoundError } from '../errors'; +import { ReaderFactory, 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 { + const headers: HeadersInit = { + Accept: 'application/vnd.github.v3.raw', + }; + + if (provider.token) { + headers.Authorization = `token ${provider.token}`; + } + + return { + headers, + }; +} + +export function getRawRequestOptions(provider: ProviderConfig): RequestInit { + const headers: HeadersInit = {}; + + if (provider.token) { + headers.Authorization = `token ${provider.token}`; + } + + return { + headers, + }; +} + +// 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 { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); + + if ( + !owner || + !name || + !ref || + (filepathtype !== 'blob' && filepathtype !== 'raw') + ) { + throw new Error('Invalid GitHub URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + return new URL( + `${provider.apiBaseUrl}/repos/${owner}/${name}/contents/${pathWithoutSlash}?ref=${ref}`, + ); + } catch (e) { + throw new Error(`Incorrect URL: ${target}, ${e}`); + } +} + +// 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 { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); + + if ( + !owner || + !name || + !ref || + (filepathtype !== 'blob' && filepathtype !== 'raw') + ) { + throw new Error('Invalid GitHub URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + return new URL( + `${provider.rawBaseUrl}/${owner}/${name}/${ref}/${pathWithoutSlash}`, + ); + } catch (e) { + throw new Error(`Incorrect URL: ${target}, ${e}`); + } +} + +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); + const predicate = (url: URL) => url.host === provider.host; + return { reader, predicate }; + }); + }; + + constructor(config: ProviderConfig) { + this.config = config; + } + + async read(url: string): Promise { + const useApi = + this.config.apiBaseUrl && (this.config.token || !this.config.rawBaseUrl); + const ghUrl = useApi + ? getApiUrl(url, this.config) + : getRawUrl(url, this.config); + const options = useApi + ? getApiRequestOptions(this.config) + : getRawRequestOptions(this.config); + + let response: Response; + try { + response = await fetch(ghUrl.toString(), options); + } catch (e) { + throw new Error(`Unable to read ${url}, ${e}`); + } + + if (response.ok) { + return response.buffer(); + } + + const message = `${url} could not be read as ${ghUrl}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + 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 new file mode 100644 index 0000000000..09da9c4e0a --- /dev/null +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -0,0 +1,122 @@ +/* + * 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '../logging'; +import { GitlabUrlReader } from './GitlabUrlReader'; + +const logger = getVoidLogger(); + +describe('GitlabUrlReader', () => { + const worker = setupServer(); + + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); + afterAll(() => worker.close()); + + beforeEach(() => { + worker.use( + rest.get('*/api/v4/projects/:name', (_, res, ctx) => + res(ctx.status(200), ctx.json({ id: 12345 })), + ), + rest.get('*', (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + url: req.url.toString(), + headers: req.headers.getAllHeaders(), + }), + ), + ), + ); + }); + afterEach(() => worker.resetHandlers()); + + const createConfig = (token?: string) => + new ConfigReader( + { + integrations: { gitlab: [{ host: 'gitlab.com', token }] }, + }, + 'test-config', + ); + + it.each([ + // Project URLs + { + url: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + config: createConfig(), + response: expect.objectContaining({ + url: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + headers: expect.objectContaining({ + 'private-token': '', + }), + }), + }, + { + url: + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + config: createConfig('0123456789'), + response: expect.objectContaining({ + url: + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + headers: expect.objectContaining({ + 'private-token': '0123456789', + }), + }), + }, + { + url: + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + config: createConfig(), + response: expect.objectContaining({ + url: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }), + }, + + // Raw URLs + { + url: 'https://gitlab.example.com/a/b/blob/master/c.yaml', + config: createConfig(), + response: expect.objectContaining({ + url: 'https://gitlab.example.com/a/b/raw/master/c.yaml', + }), + }, + ])('should handle happy path %#', async ({ url, config, response }) => { + const [{ reader }] = GitlabUrlReader.factory({ config, logger }); + + const data = await reader.read(url); + const res = await JSON.parse(data.toString('utf-8')); + expect(res).toEqual(response); + }); + + it.each([ + { + url: '', + config: createConfig(''), + error: + "Invalid type in config for key 'integrations.gitlab[0].token' in 'test-config', got empty-string, wanted string", + }, + ])('should handle error path %#', async ({ url, config, error }) => { + await expect(async () => { + const [{ reader }] = GitlabUrlReader.factory({ config, logger }); + 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 new file mode 100644 index 0000000000..0e430d650c --- /dev/null +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -0,0 +1,197 @@ +/* + * 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, { RequestInit, Response } from 'node-fetch'; +import { Config } from '@backstage/config'; +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; +} + +export class GitlabUrlReader implements UrlReader { + static factory: ReaderFactory = ({ config }) => { + return readConfig(config).map(options => { + const reader = new GitlabUrlReader(options); + const predicate = (url: URL) => url.host === options.host; + return { reader, predicate }; + }); + }; + + constructor(private readonly options: Options) {} + + async read(url: string): Promise { + // TODO(Rugvip): merged the old GitlabReaderProcessor in here and used + // the existence of /~/blob/ to switch the logic. Don't know if this + // makes sense and it might require some more work. + let builtUrl: URL; + if (url.includes('/-/blob/')) { + const projectID = await this.getProjectID(url); + builtUrl = this.buildProjectUrl(url, projectID); + } else { + builtUrl = this.buildRawUrl(url); + } + + let response: Response; + try { + response = await fetch(builtUrl.toString(), this.getRequestOptions()); + } catch (e) { + throw new Error(`Unable to read ${url}, ${e}`); + } + + if (response.ok) { + return response.buffer(); + } + + const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + // Converts + // from: https://gitlab.example.com/a/b/blob/master/c.yaml + // to: https://gitlab.example.com/a/b/raw/master/c.yaml + private buildRawUrl(target: string): URL { + try { + const url = new URL(target); + + const [ + empty, + userOrOrg, + repoName, + blobKeyword, + ...restOfPath + ] = url.pathname.split('/'); + + if ( + empty !== '' || + userOrOrg === '' || + repoName === '' || + blobKeyword !== 'blob' || + !restOfPath.join('/').match(/\.yaml$/) + ) { + throw new Error('Wrong GitLab URL'); + } + + // Replace 'blob' with 'raw' + url.pathname = [empty, userOrOrg, repoName, 'raw', ...restOfPath].join( + '/', + ); + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } + } + + // convert https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath + // to https://gitlab.com/api/v4/projects//repository/files/filepath?ref=branch + private buildProjectUrl(target: string, projectID: Number): URL { + try { + const url = new URL(target); + + const branchAndfilePath = url.pathname.split('/-/blob/')[1]; + + const [branch, ...filePath] = branchAndfilePath.split('/'); + + url.pathname = [ + '/api/v4/projects', + projectID, + 'repository/files', + encodeURIComponent(filePath.join('/')), + 'raw', + ].join('/'); + url.search = `?ref=${branch}`; + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } + } + + private async getProjectID(target: string): Promise { + const url = new URL(target); + + if ( + // absPaths to gitlab files should contain /-/blob + // ex: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath + !url.pathname.match(/\/\-\/blob\//) + ) { + throw new Error('Please provide full path to yaml file from Gitlab'); + } + try { + const repo = url.pathname.split('/-/blob/')[0]; + + // Find ProjectID from url + // convert 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath' + // to 'https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo' + const repoIDLookup = new URL( + `${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent( + repo.replace(/^\//, ''), + )}`, + ); + const response = await fetch( + repoIDLookup.toString(), + this.getRequestOptions(), + ); + const projectIDJson = await response.json(); + const projectID: Number = projectIDJson.id; + + return projectID; + } catch (e) { + throw new Error(`Could not get GitLab ProjectID for: ${target}, ${e}`); + } + } + + private getRequestOptions(): RequestInit { + return { + headers: { + ['PRIVATE-TOKEN']: this.options.token ?? '', + }, + }; + } + + toString() { + const { host, token } = this.options; + return `gitlab{host=${host},authed=${Boolean(token)}}`; + } +} diff --git a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts new file mode 100644 index 0000000000..7654cc8aac --- /dev/null +++ b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts @@ -0,0 +1,61 @@ +/* + * 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 { UrlReader, UrlReaderPredicateTuple } from './types'; + +type Options = { + // UrlReader to fall back to if no other reader is matched + fallback?: UrlReader; +}; + +/** + * A UrlReader implementation that selects from a set of UrlReaders + * based on a predicate tied to each reader. + */ +export class UrlReaderPredicateMux implements UrlReader { + private readonly readers: UrlReaderPredicateTuple[] = []; + private readonly fallback?: UrlReader; + + constructor({ fallback }: Options) { + this.fallback = fallback; + } + + register(tuple: UrlReaderPredicateTuple): void { + this.readers.push(tuple); + } + + read(url: string): Promise { + const parsed = new URL(url); + + for (const { predicate, reader } of this.readers) { + if (predicate(parsed)) { + return reader.read(url); + } + } + + if (this.fallback) { + return this.fallback.read(url); + } + + throw new Error(`No reader found that could handle '${url}'`); + } + + toString() { + return `predicateMux{readers=${this.readers + .map(t => t.reader) + .join(',')},fallback=${this.fallback}}`; + } +} diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts new file mode 100644 index 0000000000..e1d99a2c49 --- /dev/null +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -0,0 +1,84 @@ +/* + * 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 { Logger } from 'winston'; +import { Config } from '@backstage/config'; +import { ReaderFactory, UrlReader } from './types'; +import { UrlReaderPredicateMux } from './UrlReaderPredicateMux'; +import { AzureUrlReader } from './AzureUrlReader'; +import { BitbucketUrlReader } from './BitbucketUrlReader'; +import { GithubUrlReader } from './GithubUrlReader'; +import { GitlabUrlReader } from './GitlabUrlReader'; +import { FetchUrlReader } from './FetchUrlReader'; + +type CreateOptions = { + /** Root config object */ + config: Config; + /** Logger used by all the readers */ + logger: Logger; + /** A list of factories used to construct individual readers that match on URLs */ + factories?: ReaderFactory[]; + /** Fallback reader to use if none of the readers created by the factories match */ + fallback?: UrlReader; +}; + +/** + * UrlReaders provide various utilities related to the UrlReader interface. + */ +export class UrlReaders { + /** + * Creates a UrlReader without any known types. + */ + static create({ + logger, + config, + factories, + fallback, + }: CreateOptions): UrlReader { + const mux = new UrlReaderPredicateMux({ fallback: fallback }); + + for (const factory of factories ?? []) { + const tuples = factory({ config, logger: logger }); + + for (const tuple of tuples) { + mux.register(tuple); + } + } + + return mux; + } + + /** + * Creates a UrlReader that includes all the default factories from this package. + * + * Any additional factories passed will be loaded before the default ones. + * + * If no fallback reader is passed, a plain fetch reader will be used. + */ + static default({ logger, config, factories = [], fallback }: CreateOptions) { + return UrlReaders.create({ + logger, + config, + factories: factories.concat([ + AzureUrlReader.factory, + BitbucketUrlReader.factory, + GithubUrlReader.factory, + GitlabUrlReader.factory, + ]), + fallback: fallback ?? new FetchUrlReader(), + }); + } +} diff --git a/packages/backend-common/src/reading/index.ts b/packages/backend-common/src/reading/index.ts new file mode 100644 index 0000000000..8ecc08ebca --- /dev/null +++ b/packages/backend-common/src/reading/index.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. + */ + +export type { UrlReader } from './types'; +export { UrlReaders } from './UrlReaders'; +export { AzureUrlReader } from './AzureUrlReader'; +export { BitbucketUrlReader } from './BitbucketUrlReader'; +export { GithubUrlReader } from './GithubUrlReader'; +export { GitlabUrlReader } from './GitlabUrlReader'; diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts new file mode 100644 index 0000000000..e423db3eca --- /dev/null +++ b/packages/backend-common/src/reading/types.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 { Logger } from 'winston'; +import { Config } from '@backstage/config'; + +/** + * A generic interface for fetching plain data from URLs. + */ +export type UrlReader = { + read(url: string): Promise; +}; + +export type UrlReaderPredicateTuple = { + predicate: (url: URL) => boolean; + reader: UrlReader; +}; + +/** + * A factory function that can read config to construct zero or more + * UrlReaders along with a predicate for when it should be used. + */ +export type ReaderFactory = (options: { + config: Config; + logger: Logger; +}) => UrlReaderPredicateTuple[]; diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 10f9ca4d9b..5ff7ed4271 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -31,23 +31,40 @@ import { } from '../../middleware'; import { ServiceBuilder } from '../types'; import { + CspOptions, + HttpsSettings, readBaseOptions, readCorsOptions, + readCspOptions, readHttpsSettings, - HttpsSettings, } from './config'; import { createHttpServer, createHttpsServer } from './hostFactory'; import { metricsHandler } from './metrics'; -const DEFAULT_PORT = 7000; +export const DEFAULT_PORT = 7000; // '' is express default, which listens to all interfaces const DEFAULT_HOST = ''; +// taken from the helmet source code - don't seem to be exported +const DEFAULT_CSP = { + 'default-src': ["'self'"], + 'base-uri': ["'self'"], + 'block-all-mixed-content': [], + 'font-src': ["'self'", 'https:', 'data:'], + 'frame-ancestors': ["'self'"], + 'img-src': ["'self'", 'data:'], + 'object-src': ["'none'"], + 'script-src': ["'self'"], + 'script-src-attr': ["'none'"], + 'style-src': ["'self'", 'https:', "'unsafe-inline'"], + 'upgrade-insecure-requests': [], +}; export class ServiceBuilderImpl implements ServiceBuilder { private port: number | undefined; private host: string | undefined; private logger: Logger | undefined; private corsOptions: cors.CorsOptions | undefined; + private cspOptions: CspOptions | undefined; private httpsSettings: HttpsSettings | undefined; private enableMetrics: boolean = true; private routers: [string, Router][]; @@ -79,6 +96,11 @@ export class ServiceBuilderImpl implements ServiceBuilder { this.corsOptions = corsOptions; } + const cspOptions = readCspOptions(backendConfig); + if (cspOptions) { + this.cspOptions = cspOptions; + } + const httpsSettings = readHttpsSettings(backendConfig); if (httpsSettings) { this.httpsSettings = httpsSettings; @@ -115,6 +137,11 @@ export class ServiceBuilderImpl implements ServiceBuilder { return this; } + setCsp(options: CspOptions): ServiceBuilder { + this.cspOptions = options; + return this; + } + addRouter(root: string, router: Router): ServiceBuilder { this.routers.push([root, router]); return this; @@ -127,10 +154,20 @@ export class ServiceBuilderImpl implements ServiceBuilder { host, logger, corsOptions, + cspOptions, httpsSettings, } = this.getOptions(); - app.use(helmet()); + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + ...DEFAULT_CSP, + ...cspOptions, + }, + }, + }), + ); if (corsOptions) { app.use(cors(corsOptions)); } @@ -177,6 +214,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { host: string; logger: Logger; corsOptions?: cors.CorsOptions; + cspOptions?: CspOptions; httpsSettings?: HttpsSettings; } { return { @@ -184,6 +222,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { host: this.host ?? DEFAULT_HOST, logger: this.logger ?? getRootLogger(), corsOptions: this.corsOptions, + cspOptions: this.cspOptions, httpsSettings: this.httpsSettings, }; } diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index e257bd4111..bfd966b499 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; +import { Config } from '@backstage/config'; import { CorsOptions } from 'cors'; export type BaseOptions = { @@ -57,6 +57,13 @@ export type CertificateAttributes = { commonName?: string; }; +/** + * A map from CSP directive names to their values. + * + * Added here since helmet doesn't export this type publicly. + */ +export type CspOptions = Record; + /** * Reads some base options out of a config object. * @@ -71,7 +78,7 @@ export type CertificateAttributes = { * } * ``` */ -export function readBaseOptions(config: ConfigReader): BaseOptions { +export function readBaseOptions(config: Config): BaseOptions { if (typeof config.get('listen') === 'string') { // TODO(freben): Expand this to support more addresses and perhaps optional const { host, port } = parseListenAddress(config.getString('listen')); @@ -105,7 +112,7 @@ export function readBaseOptions(config: ConfigReader): BaseOptions { * } * ``` */ -export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { +export function readCorsOptions(config: Config): CorsOptions | undefined { const cc = config.getOptionalConfig('cors'); if (!cc) { return undefined; @@ -123,6 +130,33 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { }); } +/** + * Attempts to read a CSP options object from the root of a config object. + * + * @param config The root of a backend config object + * @returns A CSP options object, or undefined if not specified + * + * @example + * ```yaml + * backend: + * csp: + * connect-src: ["'self'", 'http:', 'https:'] + * ``` + */ +export function readCspOptions(config: Config): CspOptions | undefined { + const cc = config.getOptionalConfig('csp'); + if (!cc) { + return undefined; + } + + const result: CspOptions = {}; + for (const key of cc.keys()) { + result[key] = cc.getStringArray(key); + } + + return result; +} + /** * Attempts to read a https settings object from the root of a config object. * @@ -138,9 +172,7 @@ export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { * } * ``` */ -export function readHttpsSettings( - config: ConfigReader, -): HttpsSettings | undefined { +export function readHttpsSettings(config: Config): HttpsSettings | undefined { const cc = config.getOptionalConfig('https'); if (!cc) { @@ -157,7 +189,7 @@ export function readHttpsSettings( } function getOptionalStringOrStrings( - config: ConfigReader, + config: Config, key: string, ): string | string[] | undefined { const value = config.getOptional(key); diff --git a/packages/backend-common/src/setupTests.ts b/packages/backend-common/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/packages/backend-common/src/setupTests.ts +++ b/packages/backend-common/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index a7bd814b19..93929ceb86 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12 +FROM node:12-buster WORKDIR /usr/src/app diff --git a/packages/backend/package.json b/packages/backend/package.json index aa6f0ad0bf..c3a912c93e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,12 +1,12 @@ { "name": "example-backend", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "main": "dist/index.cjs.js", "types": "src/index.ts", "private": true, "license": "Apache-2.0", "engines": { - "node": "12" + "node": "12 || 14" }, "scripts": { "build": "backstage-cli backend:build", @@ -18,32 +18,34 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.22", - "@backstage/catalog-model": "^0.1.1-alpha.22", - "@backstage/config": "^0.1.1-alpha.22", - "@backstage/plugin-app-backend": "^0.1.1-alpha.22", - "@backstage/plugin-auth-backend": "^0.1.1-alpha.22", - "@backstage/plugin-catalog-backend": "^0.1.1-alpha.22", - "@backstage/plugin-graphql-backend": "^0.1.1-alpha.22", - "@backstage/plugin-identity-backend": "^0.1.1-alpha.22", - "@backstage/plugin-proxy-backend": "^0.1.1-alpha.22", - "@backstage/plugin-rollbar-backend": "^0.1.1-alpha.22", - "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.22", - "@backstage/plugin-sentry-backend": "^0.1.1-alpha.22", - "@backstage/plugin-techdocs-backend": "^0.1.1-alpha.22", + "@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", "@octokit/rest": "^18.0.0", + "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.0", - "example-app": "^0.1.1-alpha.22", + "example-app": "^0.1.1-alpha.24", "express": "^4.17.1", + "express-promise-router": "^3.0.3", "knex": "^0.21.1", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", - "sqlite3": "^4.2.0", + "sqlite3": "^5.0.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", "@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 ab32822769..959af71427 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -22,18 +22,23 @@ * Happy hacking! */ +import Router from 'express-promise-router'; import { + ensureDatabaseExists, createDatabaseClient, createServiceBuilder, loadBackendConfig, getRootLogger, useHotMemoize, + notFoundHandler, + SingleHostDiscovery, + UrlReaders, } from '@backstage/backend-common'; import { ConfigReader, AppConfig } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; -import identity from './plugins/identity'; +import kubernetes from './plugins/kubernetes'; import rollbar from './plugins/rollbar'; import scaffolder from './plugins/scaffolder'; import sentry from './plugins/sentry'; @@ -45,9 +50,14 @@ import { PluginEnvironment } from './types'; function makeCreateEnv(loadedConfigs: AppConfig[]) { const config = ConfigReader.fromConfigs(loadedConfigs); + const root = getRootLogger(); + const reader = UrlReaders.default({ logger: root, config }); + const discovery = SingleHostDiscovery.fromConfig(config); + + root.info(`Created UrlReader ${reader}`); return (plugin: string): PluginEnvironment => { - const logger = getRootLogger().child({ type: 'plugin', plugin }); + const logger = root.child({ type: 'plugin', plugin }); const database = createDatabaseClient( config.getConfig('backend.database'), { @@ -56,7 +66,7 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { }, }, ); - return { logger, database, config }; + return { logger, database, config, reader, discovery }; }; } @@ -64,30 +74,40 @@ 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 healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); - const identityEnv = useHotMemoize(module, () => createEnv('identity')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); const sentryEnv = useHotMemoize(module, () => createEnv('sentry')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); + const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); const appEnv = useHotMemoize(module, () => createEnv('app')); + const apiRouter = Router(); + apiRouter.use('/catalog', await catalog(catalogEnv)); + apiRouter.use('/rollbar', await rollbar(rollbarEnv)); + apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); + apiRouter.use('/sentry', await sentry(sentryEnv)); + apiRouter.use('/auth', await auth(authEnv)); + apiRouter.use('/techdocs', await techdocs(techdocsEnv)); + apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); + apiRouter.use('/proxy', await proxy(proxyEnv)); + apiRouter.use('/graphql', await graphql(graphqlEnv)); + apiRouter.use(notFoundHandler()); + const service = createServiceBuilder(module) .loadConfig(configReader) .addRouter('', await healthcheck(healthcheckEnv)) - .addRouter('/catalog', await catalog(catalogEnv)) - .addRouter('/rollbar', await rollbar(rollbarEnv)) - .addRouter('/scaffolder', await scaffolder(scaffolderEnv)) - .addRouter('/sentry', await sentry(sentryEnv)) - .addRouter('/auth', await auth(authEnv)) - .addRouter('/identity', await identity(identityEnv)) - .addRouter('/techdocs', await techdocs(techdocsEnv)) - .addRouter('/proxy', await proxy(proxyEnv, '/proxy')) - .addRouter('/graphql', await graphql(graphqlEnv)) + .addRouter('/api', apiRouter) .addRouter('', await app(appEnv)); await service.start().catch(err => { diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index b24dd6ca6b..913c8b783d 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -21,6 +21,7 @@ export default async function createPlugin({ logger, database, config, + discovery, }: PluginEnvironment) { - return await createRouter({ logger, config, database }); + return await createRouter({ logger, config, database, discovery }); } diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index fdac6dd938..c1d390f8cc 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -28,10 +28,11 @@ import { useHotCleanup } from '@backstage/backend-common'; export default async function createPlugin({ logger, - database, config, + reader, + database, }: PluginEnvironment) { - const locationReader = new LocationReaders({ logger, config }); + const locationReader = new LocationReaders({ logger, reader, config }); const db = await DatabaseManager.createDatabase(database, { logger }); const entitiesCatalog = new DatabaseEntitiesCatalog(db); @@ -45,7 +46,7 @@ export default async function createPlugin({ useHotCleanup( module, - runPeriodically(() => higherOrderOperation.refreshAllLocations(), 10000), + runPeriodically(() => higherOrderOperation.refreshAllLocations(), 100000), ); return await createRouter({ diff --git a/packages/backend/src/plugins/identity.ts b/packages/backend/src/plugins/kubernetes.ts similarity index 75% rename from packages/backend/src/plugins/identity.ts rename to packages/backend/src/plugins/kubernetes.ts index 63a326965c..7906765533 100644 --- a/packages/backend/src/plugins/identity.ts +++ b/packages/backend/src/plugins/kubernetes.ts @@ -14,9 +14,12 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-identity-backend'; +import { createRouter } from '@backstage/plugin-kubernetes-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin({ logger }: PluginEnvironment) { - return await createRouter({ logger }); +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); } diff --git a/packages/backend/src/plugins/proxy.ts b/packages/backend/src/plugins/proxy.ts index e96acf69d3..867e742dc0 100644 --- a/packages/backend/src/plugins/proxy.ts +++ b/packages/backend/src/plugins/proxy.ts @@ -18,9 +18,10 @@ import { createRouter } from '@backstage/plugin-proxy-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin( - { logger, config }: PluginEnvironment, - pathPrefix: string, -) { - return await createRouter({ logger, config, pathPrefix }); +export default async function createPlugin({ + logger, + config, + discovery, +}: PluginEnvironment) { + return await createRouter({ logger, config, discovery }); } diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 2793a38459..c5b36ab8da 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -20,16 +20,19 @@ import { FilePreparer, GithubPreparer, GitlabPreparer, + AzurePreparer, Preparers, Publishers, GithubPublisher, GitlabPublisher, + AzurePublisher, CreateReactAppTemplater, Templaters, - RepoVisilityOptions, + RepoVisibilityOptions, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; import { Gitlab } from '@gitbeaker/node'; +import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -46,12 +49,14 @@ export default async function createPlugin({ 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(); @@ -61,7 +66,7 @@ export default async function createPlugin({ try { const repoVisibility = githubConfig.getString( 'visibility', - ) as RepoVisilityOptions; + ) as RepoVisibilityOptions; const githubToken = githubConfig.getString('token'); const githubClient = new Octokit({ auth: githubToken }); @@ -111,6 +116,32 @@ export default async function createPlugin({ } } + 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 dockerClient = new Docker(); return await createRouter({ preparers, diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index 58dca83b43..e04d2a2732 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -22,6 +22,7 @@ import { LocalPublish, TechdocsGenerator, GithubPreparer, + GitlabPreparer, } from '@backstage/plugin-techdocs-backend'; import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -29,6 +30,7 @@ import Docker from 'dockerode'; export default async function createPlugin({ logger, config, + discovery, }: PluginEnvironment) { const generators = new Generators(); const techdocsGenerator = new TechdocsGenerator(logger, config); @@ -36,9 +38,11 @@ export default async function createPlugin({ 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); @@ -51,5 +55,6 @@ export default async function createPlugin({ dockerClient, logger, config, + discovery, }); } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index f7df3d05c6..9257fcc9cf 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -17,9 +17,12 @@ import Knex from 'knex'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common'; export type PluginEnvironment = { logger: Logger; database: Knex; config: Config; + reader: UrlReader; + discovery: PluginEndpointDiscovery; }; diff --git a/packages/catalog-model/examples/all-apis.yaml b/packages/catalog-model/examples/all-apis.yaml index 33b000d1ae..3c4f7804bf 100644 --- a/packages/catalog-model/examples/all-apis.yaml +++ b/packages/catalog-model/examples/all-apis.yaml @@ -8,3 +8,5 @@ spec: 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 diff --git a/packages/catalog-model/examples/spotify-api.yaml b/packages/catalog-model/examples/spotify-api.yaml new file mode 100644 index 0000000000..30524dfdd4 --- /dev/null +++ b/packages/catalog-model/examples/spotify-api.yaml @@ -0,0 +1,14 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: spotify + description: The Spotify web API + tags: + - spotify + - rest + annotations: + 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 diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index d683eec68d..6a543f7f84 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.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,7 +20,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.22", + "@backstage/config": "^0.1.1-alpha.24", "@types/json-schema": "^7.0.5", "@types/yup": "^0.28.2", "json-schema": "^0.2.5", @@ -29,7 +29,7 @@ "yup": "^0.29.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts index 8cc51e5aa8..ca59338c16 100644 --- a/packages/catalog-model/src/EntityPolicies.ts +++ b/packages/catalog-model/src/EntityPolicies.ts @@ -23,11 +23,12 @@ import { SchemaValidEntityPolicy, } from './entity'; import { - ApiEntityV1alpha1Policy, - ComponentEntityV1alpha1Policy, - GroupEntityV1alpha1Policy, - LocationEntityV1alpha1Policy, - TemplateEntityV1alpha1Policy, + apiEntityV1alpha1Policy, + componentEntityV1alpha1Policy, + groupEntityV1alpha1Policy, + locationEntityV1alpha1Policy, + templateEntityV1alpha1Policy, + userEntityV1alpha1Policy, } from './kinds'; import { EntityPolicy } from './types'; @@ -39,7 +40,10 @@ class AllEntityPolicies implements EntityPolicy { async enforce(entity: Entity): Promise { let result = entity; for (const policy of this.policies) { - result = await policy.enforce(entity); + const output = await policy.enforce(entity); + if (output) { + result = output; + } } return result; } @@ -50,12 +54,11 @@ 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) { - try { - return await policy.enforce(entity); - } catch { - continue; + const output = await policy.enforce(entity); + if (output !== null) { + return output; } } throw new Error(`The entity did not match any known policy`); @@ -75,11 +78,12 @@ export class EntityPolicies implements EntityPolicy { new ReservedFieldsEntityPolicy(), ]), EntityPolicies.anyOf([ - new ComponentEntityV1alpha1Policy(), - new GroupEntityV1alpha1Policy(), - new LocationEntityV1alpha1Policy(), - new TemplateEntityV1alpha1Policy(), - new ApiEntityV1alpha1Policy(), + componentEntityV1alpha1Policy, + groupEntityV1alpha1Policy, + userEntityV1alpha1Policy, + locationEntityV1alpha1Policy, + templateEntityV1alpha1Policy, + apiEntityV1alpha1Policy, ]), ]); } @@ -96,7 +100,7 @@ export class EntityPolicies implements EntityPolicy { this.policy = policy; } - enforce(entity: Entity): Promise { + 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 7f323043b1..9e0aec7de6 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -87,7 +87,7 @@ export type EntityMeta = JsonObject & { /** * The name of the entity. * - * Must be uniqe within the catalog at any given point in time, for any + * Must be unique within the catalog at any given point in time, for any * given namespace + kind pair. */ name: string; @@ -120,8 +120,3 @@ export type EntityMeta = JsonObject & { */ tags?: string[]; }; - -/** - * The keys of EntityMeta that are auto-generated. - */ -export const entityMetaGeneratedFields = ['uid', 'etag', 'generation'] as const; diff --git a/packages/catalog-model/src/entity/constants.ts b/packages/catalog-model/src/entity/constants.ts new file mode 100644 index 0000000000..42ea2ae8ba --- /dev/null +++ b/packages/catalog-model/src/entity/constants.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. + */ + +/** + * The namespace that entities without an explicit namespace fall into. + */ +export const ENTITY_DEFAULT_NAMESPACE = 'default'; + +/** + * The keys of EntityMeta that are auto-generated. + */ +export const ENTITY_META_GENERATED_FIELDS = [ + 'uid', + 'etag', + 'generation', +] as const; diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index 380f5458cc..759a94ca67 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -14,9 +14,18 @@ * limitations under the License. */ -export { entityMetaGeneratedFields } from './Entity'; +export { + ENTITY_DEFAULT_NAMESPACE, + ENTITY_META_GENERATED_FIELDS, +} from './constants'; export type { Entity, EntityMeta } from './Entity'; export * from './policies'; +export { + getEntityName, + parseEntityName, + parseEntityRef, + serializeEntityRef, +} from './ref'; export { entityHasChanges, generateEntityEtag, diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts index 68f1cec649..c6bda864cb 100644 --- a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.test.ts @@ -15,6 +15,7 @@ */ import yaml from 'yaml'; +import { ENTITY_DEFAULT_NAMESPACE } from '../constants'; import { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy'; describe('DefaultNamespaceEntityPolicy', () => { @@ -54,7 +55,10 @@ describe('DefaultNamespaceEntityPolicy', () => { await expect(result).resolves.not.toBe(withoutNamespace); await expect(result).resolves.toEqual( expect.objectContaining({ - metadata: { name: 'my-component-yay', namespace: 'default' }, + metadata: { + name: 'my-component-yay', + namespace: ENTITY_DEFAULT_NAMESPACE, + }, }), ); }); diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts index e5aba745c7..3119164454 100644 --- a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts @@ -16,6 +16,7 @@ import lodash from 'lodash'; import { EntityPolicy } from '../../types'; +import { ENTITY_DEFAULT_NAMESPACE } from '../constants'; import { Entity } from '../Entity'; /** @@ -24,7 +25,7 @@ import { Entity } from '../Entity'; export class DefaultNamespaceEntityPolicy implements EntityPolicy { private readonly namespace: string; - constructor(namespace: string = 'default') { + constructor(namespace: string = ENTITY_DEFAULT_NAMESPACE) { this.namespace = namespace; } diff --git a/packages/catalog-model/src/entity/ref.test.ts b/packages/catalog-model/src/entity/ref.test.ts new file mode 100644 index 0000000000..bd7cc9477d --- /dev/null +++ b/packages/catalog-model/src/entity/ref.test.ts @@ -0,0 +1,384 @@ +/* + * 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_DEFAULT_NAMESPACE } from './constants'; +import { Entity } from './Entity'; +import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref'; + +describe('ref', () => { + describe('parseEntityName', () => { + it('handles some omissions', () => { + expect(parseEntityName('a:b/c')).toEqual({ + kind: 'a', + namespace: 'b', + name: 'c', + }); + expect(() => parseEntityName('b/c')).toThrow(/kind/); + expect(parseEntityName('a:c')).toEqual({ + kind: 'a', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'c', + }); + expect(() => parseEntityName('c')).toThrow(/kind/); + }); + + it('rejects bad inputs', () => { + expect(() => parseEntityName(null as any)).toThrow(); + expect(() => parseEntityName(7 as any)).toThrow(); + expect(() => parseEntityName('a:b:c')).toThrow(); + expect(() => parseEntityName('a/b/c')).toThrow(); + expect(() => parseEntityName('a/b:c')).toThrow(); + expect(() => parseEntityName('a:b/c/d')).toThrow(); + expect(() => parseEntityName('a:b/c:d')).toThrow(); + }); + + it('rejects empty parts in strings', () => { + // one is empty + expect(() => parseEntityName(':b/c')).toThrow(); + expect(() => parseEntityName('a:/c')).toThrow(); + expect(() => parseEntityName('a:b/')).toThrow(); + // two are empty + expect(() => parseEntityName('a:/')).toThrow(); + expect(() => parseEntityName(':b/')).toThrow(); + expect(() => parseEntityName(':/c')).toThrow(); + // three are empty + expect(() => parseEntityName(':/')).toThrow(); + // one is left out, one empty + expect(() => parseEntityName('/c')).toThrow(); + expect(() => parseEntityName('b/')).toThrow(); + expect(() => parseEntityName(':c')).toThrow(); + expect(() => parseEntityName('a:')).toThrow(); + // nothing at all + expect(() => parseEntityName('')).toThrow(); + }); + + it('rejects empty parts in compounds', () => { + // one is empty + expect(() => + parseEntityName({ kind: '', namespace: 'b', name: 'c' }), + ).toThrow(); + expect(() => parseEntityName({ namespace: 'b', name: 'c' })).toThrow(); + expect(() => + parseEntityName({ kind: 'a', namespace: '', name: 'c' }), + ).toThrow(); + expect(() => parseEntityName({ kind: 'a', name: 'c' })).not.toThrow(); + expect(() => + parseEntityName({ kind: 'a', namespace: 'b', name: '' }), + ).toThrow(); + expect(() => + parseEntityName({ kind: 'a', namespace: 'b' } as any), + ).toThrow(); + // two are empty + expect(() => + parseEntityName({ kind: '', namespace: '', name: 'c' }), + ).toThrow(); + expect(() => parseEntityName({ name: 'c' })).toThrow(); + expect(() => + parseEntityName({ kind: '', namespace: 'b', name: '' }), + ).toThrow(); + expect(() => parseEntityName({ namespace: 'b' } as any)).toThrow(); + expect(() => + parseEntityName({ kind: 'a', namespace: '', name: '' }), + ).toThrow(); + expect(() => parseEntityName({ kind: 'a' } as any)).toThrow(); + // three are empty + expect(() => + parseEntityName({ kind: '', namespace: '', name: '' }), + ).toThrow(); + expect(() => parseEntityName({} as any)).toThrow(); + // one is left out, one empty + expect(() => parseEntityName({ namespace: '', name: 'c' })).toThrow(); + expect(() => parseEntityName({ namespace: 'b', name: '' })).toThrow(); + expect(() => parseEntityName({ kind: '', name: 'c' })).toThrow(); + expect(() => parseEntityName({ kind: 'a', name: '' })).toThrow(); + }); + + it('adds defaults where necessary to strings', () => { + expect( + parseEntityName('a:b/c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c' }); + expect( + parseEntityName('b/c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'x', namespace: 'b', name: 'c' }); + expect( + parseEntityName('a:c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); + expect(parseEntityName('a:c', { defaultKind: 'x' })).toEqual({ + kind: 'a', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'c', + }); + expect( + parseEntityName('c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); + expect(parseEntityName('c', { defaultKind: 'x' })).toEqual({ + kind: 'x', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'c', + }); + }); + + it('adds defaults where necessary to compounds', () => { + expect( + parseEntityName( + { kind: 'a', namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c' }); + expect( + parseEntityName( + { namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'x', namespace: 'b', name: 'c' }); + expect( + parseEntityName( + { kind: 'a', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); + expect( + parseEntityName({ kind: 'a', name: 'c' }, { defaultKind: 'x' }), + ).toEqual({ kind: 'a', namespace: ENTITY_DEFAULT_NAMESPACE, name: 'c' }); + expect( + parseEntityName( + { name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); + expect(parseEntityName({ name: 'c' }, { defaultKind: 'x' })).toEqual({ + kind: 'x', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'c', + }); + // empty strings are errors, not defaults + expect(() => + parseEntityName( + { kind: '', namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toThrow(/kind/); + expect(() => + parseEntityName( + { kind: 'a', namespace: '', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toThrow(/namespace/); + }); + }); + + describe('parseEntityRef', () => { + it('handles some omissions', () => { + expect(parseEntityRef('a:b/c')).toEqual({ + kind: 'a', + namespace: 'b', + name: 'c', + }); + expect(parseEntityRef('b/c')).toEqual({ + kind: undefined, + namespace: 'b', + name: 'c', + }); + expect(parseEntityRef('a:c')).toEqual({ + kind: 'a', + namespace: undefined, + name: 'c', + }); + expect(parseEntityRef('c')).toEqual({ + kind: undefined, + namespace: undefined, + name: 'c', + }); + }); + + it('rejects bad inputs', () => { + expect(() => parseEntityRef(null as any)).toThrow(); + expect(() => parseEntityRef(7 as any)).toThrow(); + expect(() => parseEntityRef('a:b:c')).toThrow(); + expect(() => parseEntityRef('a/b/c')).toThrow(); + expect(() => parseEntityRef('a/b:c')).toThrow(); + expect(() => parseEntityRef('a:b/c/d')).toThrow(); + expect(() => parseEntityRef('a:b/c:d')).toThrow(); + }); + + it('rejects empty parts in strings', () => { + // one is empty + expect(() => parseEntityRef(':b/c')).toThrow(); + expect(() => parseEntityRef('a:/c')).toThrow(); + expect(() => parseEntityRef('a:b/')).toThrow(); + // two are empty + expect(() => parseEntityRef('a:/')).toThrow(); + expect(() => parseEntityRef(':b/')).toThrow(); + expect(() => parseEntityRef(':/c')).toThrow(); + // three are empty + expect(() => parseEntityRef(':/')).toThrow(); + // one is left out, one empty + expect(() => parseEntityRef('/c')).toThrow(); + expect(() => parseEntityRef('b/')).toThrow(); + expect(() => parseEntityRef(':c')).toThrow(); + expect(() => parseEntityRef('a:')).toThrow(); + // nothing at all + expect(() => parseEntityRef('')).toThrow(); + }); + + it('rejects empty parts in compounds', () => { + // one is empty + expect(() => + parseEntityRef({ kind: '', namespace: 'b', name: 'c' }), + ).toThrow(); + expect(() => + parseEntityRef({ kind: 'a', namespace: '', name: 'c' }), + ).toThrow(); + expect(() => + parseEntityRef({ kind: 'a', namespace: 'b', name: '' }), + ).toThrow(); + // two are empty + expect(() => + parseEntityRef({ kind: '', namespace: '', name: 'c' }), + ).toThrow(); + expect(() => + parseEntityRef({ kind: '', namespace: 'b', name: '' }), + ).toThrow(); + expect(() => + parseEntityRef({ kind: 'a', namespace: '', name: '' }), + ).toThrow(); + // three are empty + expect(() => + parseEntityRef({ kind: '', namespace: '', name: '' }), + ).toThrow(); + // one is left out, one empty + expect(() => parseEntityRef({ namespace: '', name: 'c' })).toThrow(); + expect(() => parseEntityRef({ namespace: 'b', name: '' })).toThrow(); + expect(() => parseEntityRef({ kind: '', name: 'c' })).toThrow(); + expect(() => parseEntityRef({ kind: 'a', name: '' })).toThrow(); + // nothing at all + expect(() => parseEntityRef({} as any)).toThrow(); + }); + + it('adds defaults where necessary to strings', () => { + expect( + parseEntityRef('a:b/c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c' }); + expect( + parseEntityRef('b/c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'x', namespace: 'b', name: 'c' }); + expect( + parseEntityRef('a:c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); + expect( + parseEntityRef('c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); + }); + + it('adds defaults where necessary to compounds', () => { + expect( + parseEntityRef( + { kind: 'a', namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c' }); + expect( + parseEntityRef( + { namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'x', namespace: 'b', name: 'c' }); + expect( + parseEntityRef( + { kind: 'a', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); + expect( + parseEntityRef( + { name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); + // empty strings are errors, not defaults + expect(() => + parseEntityRef( + { kind: '', namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toThrow(/kind/); + expect(() => + parseEntityRef( + { kind: 'a', namespace: '', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toThrow(/namespace/); + }); + }); + + describe('serializeEntityRef', () => { + it('handles partials', () => { + expect( + serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c' }), + ).toEqual('a:b/c'); + expect(serializeEntityRef({ namespace: 'b', name: 'c' })).toEqual('b/c'); + expect(serializeEntityRef({ kind: 'a', name: 'c' })).toEqual('a:c'); + expect(serializeEntityRef({ name: 'c' })).toEqual('c'); + }); + + it('handles entities', () => { + const entityWithNamespace: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + }, + }; + const entityWithoutNamespace: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + }, + }; + expect(serializeEntityRef(entityWithNamespace)).toEqual('b:d/c'); + expect(serializeEntityRef(entityWithoutNamespace)).toEqual('b:c'); + }); + + it('picks the least complex form', () => { + expect( + serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c' }), + ).toEqual('a:b/c'); + expect(serializeEntityRef({ namespace: 'b', name: 'c' })).toEqual('b/c'); + expect(serializeEntityRef({ kind: 'a', name: 'c' })).toEqual('a:c'); + expect(serializeEntityRef({ name: 'c' })).toEqual('c'); + expect( + serializeEntityRef({ kind: 'a:x', namespace: 'b', name: 'c' }), + ).toEqual({ kind: 'a:x', namespace: 'b', name: 'c' }); + expect( + serializeEntityRef({ kind: 'a/x', namespace: 'b', name: 'c' }), + ).toEqual({ kind: 'a/x', namespace: 'b', name: 'c' }); + expect( + serializeEntityRef({ kind: 'a', namespace: 'b:x', name: 'c' }), + ).toEqual({ kind: 'a', namespace: 'b:x', name: 'c' }); + expect( + serializeEntityRef({ kind: 'a', namespace: 'b/x', name: 'c' }), + ).toEqual({ kind: 'a', namespace: 'b/x', name: 'c' }); + expect( + serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c:x' }), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c:x' }); + expect( + serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c/x' }), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c/x' }); + }); + }); +}); diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts new file mode 100644 index 0000000000..2cacfce53a --- /dev/null +++ b/packages/catalog-model/src/entity/ref.ts @@ -0,0 +1,198 @@ +/* + * 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 { EntityName, EntityRef } from '../types'; +import { ENTITY_DEFAULT_NAMESPACE } from './constants'; +import { Entity } from './Entity'; + +/** + * Extracts the kind, namespace and name that form the name triplet of the + * given entity. + * + * @param entity An entity + * @returns The complete entity name + */ +export function getEntityName(entity: Entity): EntityName { + return { + kind: entity.kind, + namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE, + name: entity.metadata.name, + }; +} + +/** + * The context of defaults that entity reference parsing happens within. + */ +type EntityRefContext = { + /** The default kind, if none is given in the reference */ + defaultKind?: string; + /** The default namespace, if none is given in the reference */ + defaultNamespace?: string; +}; + +/** + * Parses an entity reference, either on string or compound form, and always + * returns a complete entity name including kind, namespace and name. + * + * This function automatically assumes the default namespace "default" unless + * otherwise specified as part of the options, and will throw an error if no + * kind was specified in the input reference and no default kind was given. + * + * @param ref The reference to parse + * @param context The context of defaults that the parsing happens within + * @returns A complete entity name + */ +export function parseEntityName( + ref: EntityRef, + context: EntityRefContext = {}, +): EntityName { + const { kind, namespace, name } = parseEntityRef(ref, { + defaultNamespace: ENTITY_DEFAULT_NAMESPACE, + ...context, + }); + + if (!kind) { + throw new Error( + `Entity reference ${namespace}/${name} did not contain a kind`, + ); + } + + return { kind, namespace, name }; +} + +/** + * Parses an entity reference, either on string or compound form, and returns + * a structure with a name, and optional kind and namespace. + * + * The options object can contain default values for the kind and namespace, + * that will be used if the input reference did not specify any. + * + * @param ref The reference to parse + * @param context The context of defaults that the parsing happens within + * @returns The compound form of the reference + */ +export function parseEntityRef( + ref: EntityRef, + context?: { defaultKind: string }, +): { + kind: string; + namespace?: string; + name: string; +}; +export function parseEntityRef( + ref: EntityRef, + context?: { defaultNamespace: string }, +): { + kind?: string; + namespace: string; + name: string; +}; +export function parseEntityRef( + ref: EntityRef, + context?: { defaultKind: string; defaultNamespace: string }, +): { + kind: string; + namespace: string; + name: string; +}; +export function parseEntityRef( + ref: EntityRef, + context: EntityRefContext = {}, +): { + kind?: string; + namespace?: string; + name: string; +} { + if (!ref) { + throw new Error(`Entity reference must not be empty`); + } + + if (typeof ref === 'string') { + const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim()); + if (!match) { + throw new Error( + `Entity reference "${ref}" was not on the form [:][/]`, + ); + } + + return { + kind: match[1]?.slice(0, -1) ?? context.defaultKind, + namespace: match[2]?.slice(0, -1) ?? context.defaultNamespace, + name: match[3], + }; + } + + const { kind, namespace, name } = ref; + if (kind === '') { + throw new Error('Entity reference kinds must not be empty'); + } else if (namespace === '') { + throw new Error('Entity reference namespaces must not be empty'); + } else if (!name) { + throw new Error('Entity references must contain a name'); + } + + return { + kind: kind ?? context.defaultKind, + namespace: namespace ?? context.defaultNamespace, + name, + }; +} + +/** + * Takes an entity reference or name, and outputs an entity reference on the + * most compact form possible. I.e. if the parts do not contain any + * special/reserved characters, it outputs the string form, otherwise it + * outputs the compound form. + * + * @param ref The reference to serialize + * @returns The same reference on either string or compound form + */ +export function serializeEntityRef( + ref: + | Entity + | { + kind?: string; + namespace?: string; + name: string; + }, +): EntityRef { + let kind; + let namespace; + let name; + + if ('metadata' in ref) { + kind = ref.kind; + namespace = ref.metadata.namespace; + name = ref.metadata.name; + } else { + kind = ref.kind; + namespace = ref.namespace; + name = ref.name; + } + + if ( + kind?.includes(':') || + kind?.includes('/') || + namespace?.includes(':') || + namespace?.includes('/') || + name.includes(':') || + name.includes('/') + ) { + return { kind, namespace, name }; + } + + return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`; +} diff --git a/packages/catalog-model/src/index.ts b/packages/catalog-model/src/index.ts index 9469319b28..f93a4001a5 100644 --- a/packages/catalog-model/src/index.ts +++ b/packages/catalog-model/src/index.ts @@ -18,5 +18,5 @@ export * from './entity'; export { EntityPolicies } from './EntityPolicies'; export * from './kinds'; export * from './location'; -export type { EntityPolicy, JSONSchema } from './types'; +export type { EntityName, EntityPolicy, EntityRef, JSONSchema } from './types'; export * from './validation'; diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts index 35a1c59ef8..142a8d8160 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts @@ -14,15 +14,13 @@ * limitations under the License. */ -import { EntityPolicy } from '../types'; import { ApiEntityV1alpha1, - ApiEntityV1alpha1Policy, + apiEntityV1alpha1Policy as policy, } from './ApiEntityV1alpha1'; describe('ApiV1alpha1Policy', () => { let entity: ApiEntityV1alpha1; - let policy: EntityPolicy; beforeEach(() => { entity = { @@ -49,7 +47,7 @@ paths: '200': description: A paged array of pets content: - application/json: + application/json: schema: $ref: "#/components/schemas/Pets" components: @@ -74,7 +72,6 @@ components: `, }, }; - policy = new ApiEntityV1alpha1Policy(); }); it('happy path: accepts valid data', async () => { @@ -86,14 +83,14 @@ components: await expect(policy.enforce(entity)).resolves.toBe(entity); }); - it('rejects unknown apiVersion', async () => { + it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); - it('rejects unknown kind', async () => { + it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).rejects.toThrow(/kind/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); it('rejects missing type', async () => { diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 972f6df96d..052172d0a1 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -16,11 +16,24 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import type { EntityPolicy } from '../types'; +import { schemaPolicy } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'API' as const; +const schema = yup.object>({ + apiVersion: yup.string().required().oneOf(API_VERSION), + kind: yup.string().required().equals([KIND]), + spec: yup + .object({ + type: yup.string().required().min(1), + lifecycle: yup.string().required().min(1), + owner: yup.string().required().min(1), + definition: yup.string().required().min(1), + }) + .required(), +}); + export interface ApiEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -32,25 +45,4 @@ export interface ApiEntityV1alpha1 extends Entity { }; } -export class ApiEntityV1alpha1Policy implements EntityPolicy { - private schema: yup.Schema; - - constructor() { - this.schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().required().min(1), - lifecycle: yup.string().required().min(1), - owner: yup.string().required().min(1), - definition: yup.string().required().min(1), - }) - .required(), - }); - } - - async enforce(envelope: Entity): Promise { - return await this.schema.validate(envelope, { strict: true }); - } -} +export const apiEntityV1alpha1Policy = schemaPolicy(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 e784934d48..57d6d47388 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -14,15 +14,13 @@ * limitations under the License. */ -import { EntityPolicy } from '../types'; import { ComponentEntityV1alpha1, - ComponentEntityV1alpha1Policy, + componentEntityV1alpha1Policy as policy, } from './ComponentEntityV1alpha1'; describe('ComponentV1alpha1Policy', () => { let entity: ComponentEntityV1alpha1; - let policy: EntityPolicy; beforeEach(() => { entity = { @@ -38,7 +36,6 @@ describe('ComponentV1alpha1Policy', () => { implementsApis: ['api-0'], }, }; - policy = new ComponentEntityV1alpha1Policy(); }); it('happy path: accepts valid data', async () => { @@ -50,14 +47,14 @@ describe('ComponentV1alpha1Policy', () => { await expect(policy.enforce(entity)).resolves.toBe(entity); }); - it('rejects unknown apiVersion', async () => { + it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); - it('rejects unknown kind', async () => { + it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).rejects.toThrow(/kind/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); it('rejects missing type', async () => { diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 38b9c27720..9efc491ca9 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -16,11 +16,24 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import type { EntityPolicy } from '../types'; +import { schemaPolicy } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Component' as const; +const schema = yup.object>({ + apiVersion: yup.string().required().oneOf(API_VERSION), + kind: yup.string().required().equals([KIND]), + spec: 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(), + }) + .required(), +}); + export interface ComponentEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -32,25 +45,8 @@ export interface ComponentEntityV1alpha1 extends Entity { }; } -export class ComponentEntityV1alpha1Policy implements EntityPolicy { - private schema: yup.Schema; - - constructor() { - this.schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: 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(), - }) - .required(), - }); - } - - async enforce(envelope: Entity): Promise { - return await this.schema.validate(envelope, { strict: true }); - } -} +export const componentEntityV1alpha1Policy = schemaPolicy( + 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 94dcc2f14f..dc0ecd3fa3 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts @@ -14,15 +14,13 @@ * limitations under the License. */ -import { EntityPolicy } from '../types'; import { GroupEntityV1alpha1, - GroupEntityV1alpha1Policy, + groupEntityV1alpha1Policy as policy, } from './GroupEntityV1alpha1'; describe('GroupV1alpha1Policy', () => { let entity: GroupEntityV1alpha1; - let policy: EntityPolicy; beforeEach(() => { entity = { @@ -41,7 +39,6 @@ describe('GroupV1alpha1Policy', () => { descendants: ['desc-a', 'desc-b'], }, }; - policy = new GroupEntityV1alpha1Policy(); }); it('happy path: accepts valid data', async () => { @@ -53,14 +50,14 @@ describe('GroupV1alpha1Policy', () => { await expect(policy.enforce(entity)).resolves.toBe(entity); }); - it('rejects unknown apiVersion', async () => { + it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); - it('rejects unknown kind', async () => { + it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).rejects.toThrow(/kind/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); it('rejects missing type', async () => { @@ -98,6 +95,11 @@ describe('GroupV1alpha1Policy', () => { await expect(policy.enforce(entity)).resolves.toBe(entity); }); + it('accepts no ancestors', async () => { + (entity as any).spec.ancestors = []; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + it('rejects missing children', async () => { delete (entity as any).spec.children; await expect(policy.enforce(entity)).rejects.toThrow(/children/); @@ -108,6 +110,11 @@ describe('GroupV1alpha1Policy', () => { await expect(policy.enforce(entity)).resolves.toBe(entity); }); + it('accepts no children', async () => { + (entity as any).spec.children = []; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + it('rejects missing descendants', async () => { delete (entity as any).spec.descendants; await expect(policy.enforce(entity)).rejects.toThrow(/descendants/); @@ -117,4 +124,9 @@ describe('GroupV1alpha1Policy', () => { (entity as any).spec.descendants = ['']; await expect(policy.enforce(entity)).resolves.toBe(entity); }); + + it('accepts no descendants', async () => { + (entity as any).spec.descendants = []; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); }); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index c1d39d5b44..073332d707 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -16,11 +16,39 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import type { EntityPolicy } from '../types'; +import { schemaPolicy } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Group' as const; +const schema = yup.object>({ + apiVersion: yup.string().required().oneOf(API_VERSION), + kind: yup.string().required().equals([KIND]), + spec: yup + .object({ + type: yup.string().required().min(1), + 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({ + name: 'isDefined', + message: 'ancestors must be defined', + test: v => Boolean(v), + }), + children: yup.array(yup.string()).test({ + name: 'isDefined', + message: 'children must be defined', + test: v => Boolean(v), + }), + descendants: yup.array(yup.string()).test({ + name: 'isDefined', + message: 'descendants must be defined', + test: v => Boolean(v), + }), + }) + .required(), +}); + export interface GroupEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -33,26 +61,8 @@ export interface GroupEntityV1alpha1 extends Entity { }; } -export class GroupEntityV1alpha1Policy implements EntityPolicy { - private schema: yup.Schema; - - constructor() { - this.schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().required().min(1), - parent: yup.string().notRequired().min(1), - ancestors: yup.array(yup.string()).required(), - children: yup.array(yup.string()).required(), - descendants: yup.array(yup.string()).required(), - }) - .required(), - }); - } - - async enforce(envelope: Entity): Promise { - return await this.schema.validate(envelope, { strict: true }); - } -} +export const groupEntityV1alpha1Policy = schemaPolicy( + 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 62837ed500..a259eaeca8 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.test.ts @@ -14,15 +14,13 @@ * limitations under the License. */ -import { EntityPolicy } from '../types'; import { LocationEntityV1alpha1, - LocationEntityV1alpha1Policy, + locationEntityV1alpha1Policy as policy, } from './LocationEntityV1alpha1'; describe('LocationV1alpha1Policy', () => { let entity: LocationEntityV1alpha1; - let policy: EntityPolicy; beforeEach(() => { entity = { @@ -35,7 +33,6 @@ describe('LocationV1alpha1Policy', () => { type: 'github', }, }; - policy = new LocationEntityV1alpha1Policy(); }); it('happy path: accepts valid data', async () => { @@ -47,14 +44,14 @@ describe('LocationV1alpha1Policy', () => { await expect(policy.enforce(entity)).resolves.toBe(entity); }); - it('rejects unknown apiVersion', async () => { + it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); - it('rejects unknown kind', async () => { + it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).rejects.toThrow(/kind/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); it('rejects missing type', async () => { diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index a69a3a574a..6d1f1f5515 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -16,11 +16,23 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import type { EntityPolicy } from '../types'; +import { schemaPolicy } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Location' as const; +const schema = yup.object>({ + apiVersion: yup.string().required().oneOf(API_VERSION), + kind: yup.string().required().equals([KIND]), + spec: yup + .object({ + type: yup.string().required().min(1), + target: yup.string().notRequired().min(1), + targets: yup.array(yup.string()).notRequired(), + }) + .required(), +}); + export interface LocationEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -31,24 +43,8 @@ export interface LocationEntityV1alpha1 extends Entity { }; } -export class LocationEntityV1alpha1Policy implements EntityPolicy { - private schema: yup.Schema; - - constructor() { - this.schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().required().min(1), - target: yup.string().notRequired().min(1), - targets: yup.array(yup.string()).notRequired(), - }) - .required(), - }); - } - - async enforce(envelope: Entity): Promise { - return await this.schema.validate(envelope, { strict: true }); - } -} +export const locationEntityV1alpha1Policy = schemaPolicy( + 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 ce9ed8bedd..86351b41aa 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts @@ -14,15 +14,13 @@ * limitations under the License. */ -import { EntityPolicy } from '../types'; import { TemplateEntityV1alpha1, - TemplateEntityV1alpha1Policy, + templateEntityV1alpha1Policy as policy, } from './TemplateEntityV1alpha1'; -describe('TemplateEntityV1alpah1', () => { +describe('templateEntityV1alpha1', () => { let entity: TemplateEntityV1alpha1; - let policy: EntityPolicy; beforeEach(() => { entity = { @@ -52,7 +50,6 @@ describe('TemplateEntityV1alpah1', () => { }, }, }; - policy = new TemplateEntityV1alpha1Policy(); }); it('happy path: accepts valid data', async () => { @@ -64,14 +61,14 @@ describe('TemplateEntityV1alpah1', () => { await expect(policy.enforce(entity)).resolves.toBe(entity); }); - it('rejects unknown apiVersion', async () => { + it('ignores unknown apiVersion', async () => { (entity as any).apiVersion = 'backstage.io/v1beta0'; - await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); - it('rejects unknown kind', async () => { + it('ignores unknown kind', async () => { (entity as any).kind = 'Wizard'; - await expect(policy.enforce(entity)).rejects.toThrow(/kind/); + await expect(policy.enforce(entity)).resolves.toBeUndefined(); }); it('rejects missing type', async () => { diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index 8aa79d57db..a26540fda2 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -16,11 +16,25 @@ import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import type { EntityPolicy, JSONSchema } from '../types'; +import type { JSONSchema } from '../types'; +import { schemaPolicy } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Template' as const; +const schema = yup.object>({ + apiVersion: yup.string().required().oneOf(API_VERSION), + kind: yup.string().required().equals([KIND]), + spec: yup + .object({ + type: yup.string().required().min(1), + path: yup.string(), + schema: yup.object().required(), + templater: yup.string().required(), + }) + .required(), +}); + export interface TemplateEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -32,25 +46,8 @@ export interface TemplateEntityV1alpha1 extends Entity { }; } -export class TemplateEntityV1alpha1Policy implements EntityPolicy { - private schema: yup.Schema; - - constructor() { - this.schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().required().min(1), - path: yup.string(), - schema: yup.object().required(), - templater: yup.string().required(), - }) - .required(), - }); - } - - async enforce(envelope: Entity): Promise { - return await this.schema.validate(envelope, { strict: true }); - } -} +export const templateEntityV1alpha1Policy = schemaPolicy( + KIND, + API_VERSION, + schema, +); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts new file mode 100644 index 0000000000..97fe0888f1 --- /dev/null +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.test.ts @@ -0,0 +1,157 @@ +/* + * 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 { + UserEntityV1alpha1, + userEntityV1alpha1Policy as policy, +} from './UserEntityV1alpha1'; + +describe('userEntityV1alpha1Policy', () => { + let entity: UserEntityV1alpha1; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'doe', + }, + spec: { + profile: { + displayName: 'John Doe', + email: 'john@doe.org', + picture: 'https://doe.org/john.jpeg', + }, + memberOf: ['team-a', 'developers'], + }, + }; + }); + + it('happy path: accepts valid data', async () => { + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + // root + + it('silently accepts v1beta1 as well', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta1'; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + it('ignores unknown apiVersion', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta0'; + await expect(policy.enforce(entity)).resolves.toBeUndefined(); + }); + + it('ignores unknown kind', async () => { + (entity as any).kind = 'Wizard'; + await expect(policy.enforce(entity)).resolves.toBeUndefined(); + }); + + it('spec accepts unknown additional fields', async () => { + (entity as any).spec.foo = 'data'; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + // profile + + it('accepts missing profile', async () => { + delete (entity as any).spec.profile; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + it('rejects wrong profile', async () => { + (entity as any).spec.profile = 7; + await expect(policy.enforce(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); + }); + + it('profile rejects wrong displayName', async () => { + (entity as any).spec.profile.displayName = 7; + await expect(policy.enforce(entity)).rejects.toThrow(/displayName/); + }); + + it('profile rejects empty displayName', async () => { + (entity as any).spec.profile.displayName = ''; + await expect(policy.enforce(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); + }); + + it('profile rejects wrong email', async () => { + (entity as any).spec.profile.email = 7; + await expect(policy.enforce(entity)).rejects.toThrow(/email/); + }); + + it('profile rejects empty email', async () => { + (entity as any).spec.profile.email = ''; + await expect(policy.enforce(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); + }); + + it('profile rejects wrong picture', async () => { + (entity as any).spec.profile.picture = 7; + await expect(policy.enforce(entity)).rejects.toThrow(/picture/); + }); + + it('profile rejects empty picture', async () => { + (entity as any).spec.profile.picture = ''; + await expect(policy.enforce(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); + }); + + // memberOf + + it('rejects missing memberOf', async () => { + delete (entity as any).spec.memberOf; + await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + }); + + it('rejects wrong memberOf', async () => { + (entity as any).spec.memberOf = 7; + await expect(policy.enforce(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/); + }); + + it('accepts empty memberOf', async () => { + (entity as any).spec.memberOf = []; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + it('rejects null memberOf', async () => { + (entity as any).spec.memberOf = null; + await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/); + }); +}); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts new file mode 100644 index 0000000000..ff4edcc8a2 --- /dev/null +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as yup from 'yup'; +import type { Entity } from '../entity/Entity'; +import { schemaPolicy } from './util'; + +const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; +const KIND = 'User' as const; + +const schema = yup.object>({ + apiVersion: yup.string().required().oneOf(API_VERSION), + kind: yup.string().required().equals([KIND]), + spec: yup + .object({ + profile: yup + .object({ + displayName: yup.string().min(1).notRequired(), + email: yup.string().min(1).notRequired(), + picture: yup.string().min(1).notRequired(), + }) + .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({ + name: 'isDefined', + message: 'memberOf must be defined', + test: v => Boolean(v), + }), + }) + .required(), +}); + +export interface UserEntityV1alpha1 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + spec: { + profile?: { + displayName?: string; + email?: string; + picture?: string; + }; + memberOf: string[]; + }; +} + +export const userEntityV1alpha1Policy = schemaPolicy(KIND, API_VERSION, schema); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 6e8d27d204..6df4f1212a 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -14,28 +14,33 @@ * limitations under the License. */ -export { ComponentEntityV1alpha1Policy } from './ComponentEntityV1alpha1'; -export type { - ComponentEntityV1alpha1 as ComponentEntity, - ComponentEntityV1alpha1, -} from './ComponentEntityV1alpha1'; -export { GroupEntityV1alpha1Policy } from './GroupEntityV1alpha1'; -export type { - GroupEntityV1alpha1 as GroupEntity, - GroupEntityV1alpha1, -} from './GroupEntityV1alpha1'; -export { LocationEntityV1alpha1Policy } from './LocationEntityV1alpha1'; -export type { - LocationEntityV1alpha1 as LocationEntity, - LocationEntityV1alpha1, -} from './LocationEntityV1alpha1'; -export { TemplateEntityV1alpha1Policy } from './TemplateEntityV1alpha1'; -export type { - TemplateEntityV1alpha1 as TemplateEntity, - TemplateEntityV1alpha1, -} from './TemplateEntityV1alpha1'; -export { ApiEntityV1alpha1Policy } from './ApiEntityV1alpha1'; +export { apiEntityV1alpha1Policy } from './ApiEntityV1alpha1'; export type { ApiEntityV1alpha1 as ApiEntity, ApiEntityV1alpha1, } from './ApiEntityV1alpha1'; +export { componentEntityV1alpha1Policy } from './ComponentEntityV1alpha1'; +export type { + ComponentEntityV1alpha1 as ComponentEntity, + ComponentEntityV1alpha1, +} from './ComponentEntityV1alpha1'; +export { groupEntityV1alpha1Policy } from './GroupEntityV1alpha1'; +export type { + GroupEntityV1alpha1 as GroupEntity, + GroupEntityV1alpha1, +} from './GroupEntityV1alpha1'; +export { locationEntityV1alpha1Policy } from './LocationEntityV1alpha1'; +export type { + LocationEntityV1alpha1 as LocationEntity, + LocationEntityV1alpha1, +} from './LocationEntityV1alpha1'; +export { templateEntityV1alpha1Policy } from './TemplateEntityV1alpha1'; +export type { + TemplateEntityV1alpha1 as TemplateEntity, + TemplateEntityV1alpha1, +} from './TemplateEntityV1alpha1'; +export { userEntityV1alpha1Policy } from './UserEntityV1alpha1'; +export type { + UserEntityV1alpha1 as UserEntity, + UserEntityV1alpha1, +} from './UserEntityV1alpha1'; diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts new file mode 100644 index 0000000000..491b2c5e2f --- /dev/null +++ b/packages/catalog-model/src/kinds/util.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as yup from 'yup'; +import { Entity } from '../entity'; +import { EntityPolicy } from '../types'; + +export function schemaPolicy( + kind: string, + apiVersion: readonly string[], + schema: yup.Schema, +): EntityPolicy { + return { + async enforce(envelope: Entity): Promise { + if ( + kind !== envelope.kind || + !apiVersion.includes(envelope.apiVersion as any) + ) { + return undefined; + } + return await schema.validate(envelope, { strict: true }); + }, + }; +} diff --git a/packages/catalog-model/src/location/annotation.ts b/packages/catalog-model/src/location/annotation.ts index ab7a90249a..371d095685 100644 --- a/packages/catalog-model/src/location/annotation.ts +++ b/packages/catalog-model/src/location/annotation.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; diff --git a/packages/catalog-model/src/types.ts b/packages/catalog-model/src/types.ts index cba6438ccb..aa96e46102 100644 --- a/packages/catalog-model/src/types.ts +++ b/packages/catalog-model/src/types.ts @@ -27,10 +27,37 @@ export type EntityPolicy = { * Applies validation or mutation on an entity. * * @param entity The entity, as validated/mutated so far in the policy tree - * @returns The incoming entity, or a mutated version of the same + * @returns The incoming entity, or a mutated version of the same, or + * undefined if this processor could not handle the entity * @throws An error if the entity should be rejected */ - enforce(entity: Entity): Promise; + enforce(entity: Entity): Promise; }; export type JSONSchema = JSONSchema7 & { [key in string]?: JsonValue }; + +/** + * A complete entity name, with the full kind-namespace-name triplet. + */ +export type EntityName = { + kind: string; + namespace: string; + name: string; +}; + +/** + * A reference by name to an entity, either as a compact string representation, + * or as a compound reference structure. + * + * The string representation is on the form [:][/]. + * + * Left-out parts of the reference need to be handled by the application, + * either by rejecting the reference or by falling back to default values. + */ +export type EntityRef = + | string + | { + kind?: string; + namespace?: string; + name: string; + }; diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts index 200e90b406..457ef64611 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts @@ -161,18 +161,4 @@ describe('CommonValidatorFunctions', () => { ])(`isValidDnsLabel %p ? %p`, (value, result) => { expect(CommonValidatorFunctions.isValidDnsLabel(value)).toBe(result); }); - - it.each([ - ['', ''], - ['a', 'a'], - ['a-b', 'ab'], - ['-a-b', 'ab'], - ['a_b', 'ab'], - [`${'a'.repeat(6000)}`, `${'a'.repeat(6000)}`], - ['_:;>!"#â‚Ŧ', ''], - ])(`normalizeToLowercaseAlphanum %p ? %p`, (value, result) => { - expect(CommonValidatorFunctions.normalizeToLowercaseAlphanum(value)).toBe( - result, - ); - }); }); diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts index 96a91aca06..349d6d7f72 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts @@ -92,17 +92,4 @@ export class CommonValidatorFunctions { /^[a-z0-9]+(\-[a-z0-9]+)*$/.test(value) ); } - - /** - * Normalizes by keeping only a-z, A-Z, and 0-9; and converts to lowercase. - * - * @param value The value to normalize - */ - static normalizeToLowercaseAlphanum(value: string): string { - return value - .split('') - .filter(x => /[a-zA-Z0-9]/.test(x)) - .join('') - .toLowerCase(); - } } diff --git a/packages/catalog-model/src/validation/makeValidator.ts b/packages/catalog-model/src/validation/makeValidator.ts index 3602c01a63..63341f5c33 100644 --- a/packages/catalog-model/src/validation/makeValidator.ts +++ b/packages/catalog-model/src/validation/makeValidator.ts @@ -23,7 +23,6 @@ const defaultValidators: Validators = { isValidKind: KubernetesValidatorFunctions.isValidKind, isValidEntityName: KubernetesValidatorFunctions.isValidObjectName, isValidNamespace: KubernetesValidatorFunctions.isValidNamespace, - normalizeEntityName: CommonValidatorFunctions.normalizeToLowercaseAlphanum, isValidLabelKey: KubernetesValidatorFunctions.isValidLabelKey, isValidLabelValue: KubernetesValidatorFunctions.isValidLabelValue, isValidAnnotationKey: KubernetesValidatorFunctions.isValidAnnotationKey, diff --git a/packages/catalog-model/src/validation/types.ts b/packages/catalog-model/src/validation/types.ts index ff00036991..14706485ca 100644 --- a/packages/catalog-model/src/validation/types.ts +++ b/packages/catalog-model/src/validation/types.ts @@ -19,7 +19,6 @@ export type Validators = { isValidKind(value: any): boolean; isValidEntityName(value: any): boolean; isValidNamespace(value: any): boolean; - normalizeEntityName(value: string): string; isValidLabelKey(value: any): boolean; isValidLabelValue(value: any): boolean; isValidAnnotationKey(value: any): boolean; diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index 1340b46ae3..abb4b5f9a1 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.22", + "version": "0.1.1-alpha.24", "private": false, "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json index ef1a8df353..62609aad5b 100644 --- a/packages/cli/config/tsconfig.json +++ b/packages/cli/config/tsconfig.json @@ -11,7 +11,7 @@ "incremental": true, "isolatedModules": true, "jsx": "react", - "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019"], + "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019", "ESNext.Promise"], "module": "ESNext", "moduleResolution": "node", "noEmit": false, diff --git a/packages/cli/package.json b/packages/cli/package.json index 7f84217ec5..c336684ba5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public" @@ -28,9 +28,9 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { - "@backstage/cli-common": "^0.1.1-alpha.22", - "@backstage/config": "^0.1.1-alpha.22", - "@backstage/config-loader": "^0.1.1-alpha.22", + "@backstage/cli-common": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", + "@backstage/config-loader": "^0.1.1-alpha.24", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", @@ -54,7 +54,7 @@ "css-loader": "^3.5.3", "dashify": "^2.0.0", "diff": "^4.0.2", - "esbuild": "0.6.3", + "esbuild": "^0.7.7", "eslint": "^7.1.0", "eslint-formatter-friendly": "^7.0.0", "eslint-plugin-import": "^2.20.2", @@ -78,10 +78,10 @@ "rollup": "2.23.x", "rollup-plugin-dts": "1.4.11", "rollup-plugin-esbuild": "^2.0.0", - "rollup-plugin-image-files": "^1.4.2", "rollup-plugin-peer-deps-external": "^2.2.2", "rollup-plugin-postcss": "^3.1.1", - "rollup-plugin-typescript2": "^0.26.0", + "rollup-plugin-typescript2": "^0.27.3", + "rollup-pluginutils": "^2.8.2", "start-server-webpack-plugin": "^2.2.5", "style-loader": "^1.2.1", "sucrase": "^3.14.1", @@ -91,7 +91,7 @@ "typescript": "^3.9.3", "url-loader": "^4.1.0", "webpack": "^4.41.6", - "webpack-dev-server": "^3.10.3", + "webpack-dev-server": "^3.11.0", "webpack-node-externals": "^2.5.0", "yaml": "^1.10.0", "yml-loader": "^2.1.0", @@ -112,8 +112,9 @@ "@types/rollup-plugin-postcss": "^2.0.0", "@types/tar": "^4.0.3", "@types/webpack": "^4.41.7", - "@types/webpack-dev-server": "^3.10.0", + "@types/webpack-dev-server": "^3.11.0", "del": "^5.1.0", + "mock-fs": "^4.13.0", "nodemon": "^2.0.2", "ts-node": "^8.6.2" }, diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 5e6882a4a0..cbaf956bdb 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -21,6 +21,7 @@ import inquirer, { Answers, Question } from 'inquirer'; import { exec as execCb } from 'child_process'; import { resolve as resolvePath } from 'path'; import os from 'os'; +import { Command } from 'commander'; import { parseOwnerIds, addCodeownersEntry, @@ -32,12 +33,12 @@ import { version as backstageVersion } from '../../lib/version'; const exec = promisify(execCb); -async function checkExists(rootDir: string, id: string) { - await Task.forItem('checking', id, async () => { - const destination = resolvePath(rootDir, 'plugins', id); - +async function checkExists(destination: string) { + await Task.forItem('checking', destination, async () => { if (await fs.pathExists(destination)) { - const existing = chalk.cyan(destination.replace(`${rootDir}/`, '')); + const existing = chalk.cyan( + destination.replace(`${paths.targetRoot}/`, ''), + ); throw new Error( `A plugin with the same name already exists: ${existing}\nPlease try again with a different plugin ID`, ); @@ -86,10 +87,9 @@ export const addExportStatement = async ( export async function addPluginDependencyToApp( rootDir: string, - pluginName: string, + pluginPackage: string, versionStr: string, ) { - const pluginPackage = `@backstage/plugin-${pluginName}`; const packageFilePath = 'packages/app/package.json'; const packageFile = resolvePath(rootDir, packageFilePath); @@ -116,8 +116,11 @@ export async function addPluginDependencyToApp( }); } -export async function addPluginToApp(rootDir: string, pluginName: string) { - const pluginPackage = `@backstage/plugin-${pluginName}`; +export async function addPluginToApp( + rootDir: string, + pluginName: string, + pluginPackage: string, +) { const pluginNameCapitalized = pluginName .split('-') .map(name => capitalize(name)) @@ -175,7 +178,7 @@ export async function movePlugin( }); } -export default async () => { +export default async (cmd: Command) => { const codeownersPath = await getCodeownersFilePath(paths.targetRoot); const questions: Question[] = [ @@ -221,29 +224,46 @@ export default async () => { } const answers: Answers = await inquirer.prompt(questions); - + const name = cmd.scope + ? `@${cmd.scope.replace(/^@/, '')}/plugin-${answers.id}` + : `plugin-${answers.id}`; + const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : ''; + const privatePackage = cmd.private === false ? false : true; + const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json')); const appPackage = paths.resolveTargetRoot('packages/app'); - const templateDir = paths.resolveOwn('templates/default-plugin'); + const templateDir = paths.resolveOwn( + cmd.backend + ? 'templates/default-backend-plugin' + : 'templates/default-plugin', + ); const tempDir = resolvePath(os.tmpdir(), answers.id); - const pluginDir = paths.resolveTargetRoot('plugins', answers.id); + const pluginDir = isMonoRepo + ? paths.resolveTargetRoot('plugins', answers.id) + : paths.resolveTargetRoot(answers.id); const ownerIds = parseOwnerIds(answers.owner); - const { version } = await fs.readJson(paths.resolveTargetRoot('lerna.json')); + const { version } = isMonoRepo + ? await fs.readJson(paths.resolveTargetRoot('lerna.json')) + : { version: '0.1.0' }; Task.log(); Task.log('Creating the plugin...'); try { Task.section('Checking if the plugin ID is available'); - await checkExists(paths.targetRoot, answers.id); + 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, }); Task.section('Moving to final location'); @@ -252,12 +272,12 @@ export default async () => { Task.section('Building the plugin'); await buildPlugin(pluginDir); - if (await fs.pathExists(appPackage)) { + if ((await fs.pathExists(appPackage)) && !cmd.backend) { Task.section('Adding plugin as dependency in app'); - await addPluginDependencyToApp(paths.targetRoot, answers.id, version); + await addPluginDependencyToApp(paths.targetRoot, name, version); Task.section('Import plugin in app'); - await addPluginToApp(paths.targetRoot, answers.id); + await addPluginToApp(paths.targetRoot, answers.id, name); } if (ownerIds && ownerIds.length) { @@ -269,11 +289,7 @@ export default async () => { } Task.log(); - Task.log( - `đŸĨ‡ Successfully created ${chalk.cyan( - `@backstage/plugin-${answers.id}`, - )}`, - ); + Task.log(`đŸĨ‡ Successfully created ${chalk.cyan(`${name}`)}`); Task.log(); Task.exit(); } catch (error) { diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 4e031cdbc1..fea250cd55 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -61,7 +61,14 @@ export function registerCommands(program: CommanderStatic) { program .command('create-plugin') + .option( + '--backend', + 'Create plugin with the backend dependencies as default', + ) .description('Creates a new plugin in the current repository') + .option('--scope ', 'NPM scope') + .option('--npm-registry ', 'NPM registry URL') + .option('--no-private', 'Public NPM Package') .action( lazy(() => import('./create-plugin/createPlugin').then(m => m.default)), ); diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts index 99af093f2c..f5dfa670b7 100644 --- a/packages/cli/src/commands/plugin/diff.ts +++ b/packages/cli/src/commands/plugin/diff.ts @@ -30,6 +30,9 @@ import { version as backstageVersion } from '../../lib/version'; export type PluginData = { id: string; name: string; + privatePackage: string; + version: string; + npmRegistry: string; }; const fileHandlers = [ @@ -62,11 +65,8 @@ export default async (cmd: Command) => { promptFunc = yesPromptFunc; } - const { version } = await fs.readJson(paths.resolveTargetRoot('lerna.json')); - const data = await readPluginData(); const templateFiles = await diffTemplateFiles('default-plugin', { - version, backstageVersion, ...data, }); @@ -77,9 +77,19 @@ export default async (cmd: Command) => { // Reads templating data from the existing plugin async function readPluginData(): Promise { let name: string; + let privatePackage: string; + let version: string; + let npmRegistry: string; try { const pkg = require(paths.resolveTarget('package.json')); name = pkg.name; + privatePackage = pkg.private; + version = pkg.version; + const scope = name.split('/')[0]; + if (`${scope}:registry` in pkg.publishConfig) { + const registryURL = pkg.publishConfig[`${scope}:registry`]; + npmRegistry = `"${scope}:registry" : "${registryURL}"`; + } else npmRegistry = ''; } catch (error) { throw new Error(`Failed to read target package, ${error}`); } @@ -96,5 +106,5 @@ async function readPluginData(): Promise { const id = pluginIdMatch[1]; - return { id, name }; + return { id, name, privatePackage, version, npmRegistry }; } diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index b899fc98d3..b9ce04a0ae 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -22,13 +22,13 @@ import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import postcss from 'rollup-plugin-postcss'; import esbuild from 'rollup-plugin-esbuild'; -import imageFiles from 'rollup-plugin-image-files'; import svgr from '@svgr/rollup'; import dts from 'rollup-plugin-dts'; import json from '@rollup/plugin-json'; import yaml from '@rollup/plugin-yaml'; import { RollupOptions, OutputOptions } from 'rollup'; +import { forwardFileImports } from './plugins'; import { BuildOptions, Output } from './types'; import { paths } from '../paths'; import { svgrTemplate } from '../svgrTemplate'; @@ -107,7 +107,7 @@ export const makeConfigs = async ( exclude: [/\/[^/]+\.(?:stories|test)\.[^/]+$/], }), postcss(), - imageFiles({ + forwardFileImports({ exclude: /\.icon\.svg$/, include: [/\.svg$/, /\.png$/, /\.gif$/, /\.jpg$/, /\.jpeg$/], }), diff --git a/packages/cli/src/lib/builder/plugins.test.ts b/packages/cli/src/lib/builder/plugins.test.ts new file mode 100644 index 0000000000..41504a700e --- /dev/null +++ b/packages/cli/src/lib/builder/plugins.test.ts @@ -0,0 +1,178 @@ +/* + * 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 { + NormalizedOutputOptions, + OutputAsset, + OutputChunk, + PluginContext, +} from 'rollup'; + +import { forwardFileImports } from './plugins'; + +const context = { + meta: { + rollupVersion: '0.0.0', + watchMode: false, + }, +} as PluginContext; + +describe('forwardFileImports', () => { + it('should be created', () => { + const plugin = forwardFileImports({ include: /\.png$/ }); + expect(plugin.name).toBe('forward-file-imports'); + }); + + it('should call through to original external option', () => { + const plugin = forwardFileImports({ include: /\.png$/ }); + const external = jest.fn((id: string) => id.endsWith('external')); + + const options = plugin.options?.call(context, { external })!; + if (typeof options.external !== 'function') { + throw new Error('options.external is not a function'); + } + + expect(external).toHaveBeenCalledTimes(0); + expect(options.external('./my-module', '/dev/src/index.ts', false)).toBe( + false, + ); + expect(external).toHaveBeenCalledTimes(1); + expect(options.external('./my-external', '/dev/src/index.ts', false)).toBe( + true, + ); + expect(external).toHaveBeenCalledTimes(2); + expect(options.external('./my-image.png', '/dev/src/index.ts', false)).toBe( + true, + ); + expect(external).toHaveBeenCalledTimes(3); + expect(options.external('./my-image.png', '/dev/src/index.ts', true)).toBe( + true, + ); + expect(external).toHaveBeenCalledTimes(4); + + expect(() => + (options as any).external('./my-image.png', undefined, false), + ).toThrow('Unknown importer of file module ./my-image.png'); + }); + + it('should handle original external array', () => { + const plugin = forwardFileImports({ include: /\.png$/ }); + + const options = plugin.options?.call(context, { + external: ['my-external'], + })!; + if (typeof options.external !== 'function') { + throw new Error('options.external is not a function'); + } + + expect(options.external('my-module', '/dev/src/index.ts', false)).toBe( + false, + ); + expect(options.external('my-external', '/dev/src/index.ts', false)).toBe( + true, + ); + expect(options.external('my-image.png', '/dev/src/index.ts', false)).toBe( + true, + ); + }); + + describe('with mock fs', () => { + beforeEach(() => { + mockFs({ + '/dev/src/my-module.ts': '', + '/dev/src/dir/my-image.png': 'my-image', + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should extract files', async () => { + const plugin = forwardFileImports({ include: /\.png$/ }); + + const options = plugin.options?.call(context, {})!; + if (typeof options.external !== 'function') { + throw new Error('options.external is not a function'); + } + + expect(options.external('./my-module', '/dev/src/index.ts', false)).toBe( + false, + ); + expect( + options.external('./my-image.png', '/dev/src/dir/index.ts', false), + ).toBe(true); + + const outPath = '/dev/dist/dir/my-image.png'; + await expect(fs.pathExists(outPath)).resolves.toBe(false); + + await plugin.generateBundle?.call( + context, + { dir: '/dev/dist' } as NormalizedOutputOptions, + { + ['index.js']: { + type: 'chunk', + facadeModuleId: '/dev/src/index.ts', + } as OutputChunk, + }, + false, // isWrite = false -> no write + ); + await expect(fs.pathExists(outPath)).resolves.toBe(false); + + await plugin.generateBundle?.call( + context, + { dir: '/dev/dist' } as NormalizedOutputOptions, + { + // output assets should not cause a write + ['index.js']: { type: 'asset' } as OutputAsset, + // missing facadeModuleId should not cause a write either + ['index2.js']: { type: 'chunk' } as OutputChunk, + }, + true, + ); + await expect(fs.pathExists(outPath)).resolves.toBe(false); + + // output chunk + isWrite -> generate files + await plugin.generateBundle?.call( + context, + { dir: '/dev/dist' } as NormalizedOutputOptions, + { + ['index.js']: { + type: 'chunk', + facadeModuleId: '/dev/src/index.ts', + } as OutputChunk, + }, + true, + ); + await expect(fs.pathExists(outPath)).resolves.toBe(true); + + // should not break when triggering another write + await plugin.generateBundle?.call( + context, + { file: '/dev/dist/my-output.js' } as NormalizedOutputOptions, + { + ['index.js']: { + type: 'chunk', + facadeModuleId: '/dev/src/index.ts', + } as OutputChunk, + }, + true, + ); + }); + }); +}); diff --git a/packages/cli/src/lib/builder/plugins.ts b/packages/cli/src/lib/builder/plugins.ts new file mode 100644 index 0000000000..48b6b65c61 --- /dev/null +++ b/packages/cli/src/lib/builder/plugins.ts @@ -0,0 +1,135 @@ +/* + * 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 { + dirname, + resolve as resolvePath, + relative as relativePath, +} from 'path'; +import { createFilter } from 'rollup-pluginutils'; +import { Plugin, InputOptions, OutputChunk } from 'rollup'; + +type ForwardFileImportsOptions = { + include: Array | string | RegExp | null; + exclude?: Array | string | RegExp | null; +}; + +/** + * This rollup plugin leaves all encountered asset imports as-is, but + * copies the imported files into the output directory. + * + * For example `import ImageUrl from './my-image.png'` inside `src/MyComponent` will + * cause `src/MyComponent/my-image.png` to be copied to the output directory at the + * path `dist/MyComponent/my-image.png`. The import itself will stay, but be resolved, + * resulting in something like `import ImageUrl from './MyComponent/my-image.png'` + */ +export function forwardFileImports(options: ForwardFileImportsOptions): Plugin { + const filter = createFilter(options.include, options.exclude); + + // We collect the absolute paths to all files we want to bundle into the + // output dir here. Resolving to relative paths in the output dir happens later. + const exportedFiles = new Set(); + + // We keep track of output directories that we've already copied files + // into, so that we don't duplicate that work + const generatedFor = new Set(); + + return { + name: 'forward-file-imports', + async generateBundle(outputOptions, bundle, isWrite) { + if (!isWrite) { + return; + } + + const dir = outputOptions.dir || dirname(outputOptions.file!); + if (generatedFor.has(dir)) { + return; + } + + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') { + continue; + } + const chunk = output as OutputChunk; + + // This'll be an absolute path pointing to the initial index file of the + // build, and we use it to find the location of the `src` dir + if (!chunk.facadeModuleId) { + continue; + } + generatedFor.add(dir); + + // We're assuming that the index file is at the root of the source dir, and + // that all assets exist within that dir. + const srcRoot = dirname(chunk.facadeModuleId); + + // Copy all the files we found into the dist dir + await Promise.all( + Array.from(exportedFiles).map(async exportedFile => { + const outputPath = relativePath(srcRoot, exportedFile); + const targetFile = resolvePath(dir, outputPath); + + await fs.ensureDir(dirname(targetFile)); + await fs.copyFile(exportedFile, targetFile); + }), + ); + return; + } + }, + options(inputOptions) { + const origExternal = inputOptions.external; + + // We decorate any existing `external` option with our own way of determining + // if a module should be external. The can't use `resolveId`, since asset files + // aren't passed there, might be some better way to do this though. + const external: InputOptions['external'] = (id, importer, isResolved) => { + // Call to inner external option + if ( + typeof origExternal === 'function' && + origExternal(id, importer, isResolved) + ) { + return true; + } + + if (Array.isArray(origExternal) && origExternal.includes(id)) { + return true; + } + + // The piece that we're adding + if (!filter(id)) { + return false; + } + + // Sanity check, dunno if this can happen + if (!importer) { + throw new Error(`Unknown importer of file module ${id}`); + } + + // Resolve relative imports to the full file URL, for deduping and copying later + const fullId = isResolved ? id : resolvePath(dirname(importer), id); + exportedFiles.add(fullId); + + // Treating this module as external from here, meaning rollup won't try to + // put it in the output bundle, but still keep track of the relative imports + // as needed in the output code. + return true; + }; + + return { ...inputOptions, external }; + }, + }; +} diff --git a/plugins/identity-backend/.eslintrc.js b/packages/cli/templates/default-backend-plugin/.eslintrc.js similarity index 100% rename from plugins/identity-backend/.eslintrc.js rename to packages/cli/templates/default-backend-plugin/.eslintrc.js diff --git a/packages/cli/templates/default-backend-plugin/README.md.hbs b/packages/cli/templates/default-backend-plugin/README.md.hbs new file mode 100644 index 0000000000..5c34b40360 --- /dev/null +++ b/packages/cli/templates/default-backend-plugin/README.md.hbs @@ -0,0 +1,14 @@ +# {{id}} + +Welcome to the {{id}} backend plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn +start` in the root directory, and then navigating to [/{{id}}](http://localhost:3000/{{id}}). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs new file mode 100644 index 0000000000..40e612609a --- /dev/null +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -0,0 +1,44 @@ +{ + "name": "{{name}}", + "version": "{{version}}", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + {{#if privatePackage}} "private": {{privatePackage}}, + {{/if}} + "publishConfig": { + {{#if npmRegistry}} "registry": "{{npmRegistry}}", + {{/if}} + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^{{version}}", + "@backstage/config": "^{{version}}", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "express-promise-router": "^3.0.3", + "winston": "^3.2.1", + "node-fetch": "^2.6.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^{{version}}", + "@types/supertest": "^2.0.8", + "supertest": "^4.0.2", + "msw": "^0.20.5" + }, + "files": [ + "dist" + ] + } diff --git a/plugins/identity-backend/src/index.ts b/packages/cli/templates/default-backend-plugin/src/index.ts similarity index 100% rename from plugins/identity-backend/src/index.ts rename to packages/cli/templates/default-backend-plugin/src/index.ts diff --git a/packages/cli/templates/default-backend-plugin/src/run.ts.hbs b/packages/cli/templates/default-backend-plugin/src/run.ts.hbs new file mode 100644 index 0000000000..b96989e4b8 --- /dev/null +++ b/packages/cli/templates/default-backend-plugin/src/run.ts.hbs @@ -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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/packages/cli/templates/default-backend-plugin/src/service/router.test.ts b/packages/cli/templates/default-backend-plugin/src/service/router.test.ts new file mode 100644 index 0000000000..0aaeafa379 --- /dev/null +++ b/packages/cli/templates/default-backend-plugin/src/service/router.test.ts @@ -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 { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/identity-backend/src/service/router.ts b/packages/cli/templates/default-backend-plugin/src/service/router.ts similarity index 60% rename from plugins/identity-backend/src/service/router.ts rename to packages/cli/templates/default-backend-plugin/src/service/router.ts index 9d16e8f332..3ea8219365 100644 --- a/plugins/identity-backend/src/service/router.ts +++ b/packages/cli/templates/default-backend-plugin/src/service/router.ts @@ -14,37 +14,27 @@ * limitations under the License. */ +import { errorHandler } from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { StaticJsonAdapter } from '../adapters'; -import { IdentityApi } from '../adapters/types'; -import { userGroups } from '../adapters/userGroups'; export interface RouterOptions { logger: Logger; } -const makeRouter = (adapter: IdentityApi): express.Router => { - const router = Router(); - router.use(express.json()); - - router.get('/users/:user/groups', async (req, res) => { - const user = req.params.user; - const type = req.query.type?.toString() ?? ''; - const response = await adapter.getUserGroups({ user, type }); - res.send(response); - }); - - return router; -}; - export async function createRouter( options: RouterOptions, ): Promise { - const logger = options.logger; + const { logger } = options; - logger.info('Initializing identity API backend'); - const adapter = new StaticJsonAdapter(userGroups); - return makeRouter(adapter); + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.send({ status: 'ok' }); + }); + router.use(errorHandler()); + return router; } diff --git a/packages/cli/templates/default-backend-plugin/src/service/standaloneServer.ts.hbs b/packages/cli/templates/default-backend-plugin/src/service/standaloneServer.ts.hbs new file mode 100644 index 0000000000..6e38965246 --- /dev/null +++ b/packages/cli/templates/default-backend-plugin/src/service/standaloneServer.ts.hbs @@ -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 { createServiceBuilder } from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: '{{id}}-backend' }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + }); + + const service = createServiceBuilder(module) + .enableCors({ origin: 'http://localhost:3000' }) + .addRouter('/{{id}}', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/packages/cli/templates/default-backend-plugin/src/setupTests.ts b/packages/cli/templates/default-backend-plugin/src/setupTests.ts new file mode 100644 index 0000000000..a5907fd52f --- /dev/null +++ b/packages/cli/templates/default-backend-plugin/src/setupTests.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 {}; +global.fetch = require('node-fetch'); diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index dbfb639b16..e024884547 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -1,11 +1,14 @@ { - "name": "@backstage/plugin-{{id}}", + "name": "{{name}}", "version": "{{version}}", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, +{{#if privatePackage}} "private": {{privatePackage}}, +{{/if}} "publishConfig": { +{{#if npmRegistry}} "registry": "{{npmRegistry}}", +{{/if}} "access": "public", "main": "dist/index.esm.js", "types": "dist/index.d.ts" @@ -38,7 +41,8 @@ "@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" }, "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 37759313ee..fdb39444d8 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 @@ -1,18 +1,34 @@ import React from 'react'; import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; import ExampleComponent from './ExampleComponent'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + 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()) + + // setup mock response + beforeEach(() => { + server.use(rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({})))) + }) + it('should render', () => { - mockFetch.mockResponse(() => new Promise(() => {})); const rendered = render( , - ); - expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument(); + ); + expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument(); }); }); 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 cc61c215cd..ca1990b4bc 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 @@ -1,11 +1,25 @@ import React from 'react'; import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; import ExampleFetchComponent from './ExampleFetchComponent'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; 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()) + + // setup mock response + beforeEach(() => { + server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({})))) + }) it('should render', async () => { - mockFetch.mockResponse(() => new Promise(() => {})); const rendered = render(); expect(await rendered.findByTestId('progress')).toBeInTheDocument(); }); diff --git a/packages/cli/templates/default-plugin/src/setupTests.ts b/packages/cli/templates/default-plugin/src/setupTests.ts index 3fa8703ac4..cc559f672e 100644 --- a/packages/cli/templates/default-plugin/src/setupTests.ts +++ b/packages/cli/templates/default-plugin/src/setupTests.ts @@ -1,3 +1,2 @@ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); +global.fetch = require('node-fetch'); diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 5960b553f7..6a17eba76c 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.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.22", + "@backstage/config": "^0.1.1-alpha.24", "fs-extra": "^9.0.0", "yaml": "^1.9.2", "yup": "^0.29.1" diff --git a/packages/config-loader/src/lib/reader.test.ts b/packages/config-loader/src/lib/reader.test.ts index 238a556061..6ccceef60d 100644 --- a/packages/config-loader/src/lib/reader.test.ts +++ b/packages/config-loader/src/lib/reader.test.ts @@ -84,6 +84,47 @@ describe('readConfigFile', () => { }); it('should read secrets', async () => { + const readFile = memoryFiles({ + './app-config.yaml': 'app: { $file: "./my-secret" }', + }); + const readSecret = jest.fn().mockResolvedValue('secret'); + + const config = readConfigFile('./app-config.yaml', { + ...mockContext, + readFile, + readSecret: readSecret as ReadSecretFunc, + }); + + await expect(config).resolves.toEqual({ + data: { + app: 'secret', + }, + context: 'app-config.yaml', + }); + expect(readSecret).toHaveBeenCalledWith('.app', { + file: './my-secret', + }); + }); + + it('should not allow keys adjacent to secrets', async () => { + const readFile = memoryFiles({ + './app-config.yaml': 'app: { extraKey: 3, $file: "./my-secret" }', + }); + const readSecret = jest.fn().mockResolvedValue('secret'); + + const config = readConfigFile('./app-config.yaml', { + ...mockContext, + readFile, + readSecret: readSecret as ReadSecretFunc, + }); + + await expect(config).rejects.toThrow( + "Secret key '$file' has adjacent keys at .app", + ); + expect(readSecret).not.toHaveBeenCalled(); + }); + + it('should read deprecated secrets', async () => { const readFile = memoryFiles({ './app-config.yaml': 'app: { $secret: { file: "./my-secret" } }', }); @@ -106,7 +147,7 @@ describe('readConfigFile', () => { }); }); - it('should require secrets to be objects', async () => { + it('should require deprecated secrets to be objects', async () => { const readFile = memoryFiles({ './app-config.yaml': 'app: { $secret: ["wrong-type"] }', }); diff --git a/packages/config-loader/src/lib/reader.ts b/packages/config-loader/src/lib/reader.ts index e2c9bebdf3..8eadae0fe0 100644 --- a/packages/config-loader/src/lib/reader.ts +++ b/packages/config-loader/src/lib/reader.ts @@ -31,6 +31,8 @@ export async function readConfigFile( const configYaml = await ctx.readFile(filePath); const config = yaml.parse(configYaml); + const context = basename(filePath); + async function transform( obj: JsonValue, path: string, @@ -56,7 +58,11 @@ export async function readConfigFile( return arr; } + // TODO(Rugvip): This form of declaring secrets is deprecated, warn and remove in the future if ('$secret' in obj) { + console.warn( + `Deprecated secret declaration at '${path}' in '${context}', use $env, $file, etc. instead`, + ); if (!isObject(obj.$secret)) { throw TypeError(`Expected object at secret ${path}.$secret`); } @@ -68,6 +74,24 @@ export async function readConfigFile( } } + // Check if there's any key that starts with a '$', in that case we treat + // this entire object as a secret. + const [secretKey] = Object.keys(obj).filter(key => key.startsWith('$')); + if (secretKey) { + if (Object.keys(obj).length !== 1) { + throw new Error( + `Secret key '${secretKey}' has adjacent keys at ${path}`, + ); + } + try { + return await ctx.readSecret(path, { + [secretKey.slice(1)]: obj[secretKey], + }); + } catch (error) { + throw new Error(`Invalid secret at ${path}: ${error.message}`); + } + } + const out: JsonObject = {}; for (const [key, value] of Object.entries(obj)) { @@ -87,5 +111,5 @@ export async function readConfigFile( if (!isObject(finalConfig)) { throw new TypeError('Expected object at config root'); } - return { data: finalConfig, context: basename(filePath) }; + return { data: finalConfig, context }; } diff --git a/packages/config-loader/src/lib/secrets.test.ts b/packages/config-loader/src/lib/secrets.test.ts index fd95e527af..cfc4150d68 100644 --- a/packages/config-loader/src/lib/secrets.test.ts +++ b/packages/config-loader/src/lib/secrets.test.ts @@ -56,21 +56,33 @@ describe('readSecret', () => { }); it('should read data secrets', async () => { + // Deprecated object form await expect( readSecret({ data: 'my-data.json', path: 'a.b.c' }, ctx), ).resolves.toBe('42'); - await expect( readSecret({ data: 'my-data.yaml', path: 'some.yaml.key' }, ctx), ).resolves.toBe('7'); - await expect( readSecret({ data: 'my-data.yml', path: 'different.key' }, ctx), ).resolves.toBe('hello'); - await expect( readSecret({ data: 'no-data.yml', path: 'different.key' }, ctx), ).rejects.toThrow('File not found!'); + + // New format with path in fragment + await expect(readSecret({ data: 'my-data.json#a.b.c' }, ctx)).resolves.toBe( + '42', + ); + await expect( + readSecret({ data: 'my-data.yaml#some.yaml.key' }, ctx), + ).resolves.toBe('7'); + await expect( + readSecret({ data: 'my-data.yml#different.key' }, ctx), + ).resolves.toBe('hello'); + await expect( + readSecret({ data: 'no-data.yml#different.key' }, ctx), + ).rejects.toThrow('File not found!'); }); it('should reject invalid secrets', async () => { @@ -84,7 +96,7 @@ describe('readSecret', () => { "Secret must contain one of 'file', 'env', 'data'", ); await expect(readSecret({ data: 'no-data.yml' }, ctx)).rejects.toThrow( - 'path is a required field', + "Invalid format for data secret value, must be of the form #, got 'no-data.yml'", ); await expect( readSecret({ data: 'no-parser.js', path: '.' }, ctx), diff --git a/packages/config-loader/src/lib/secrets.ts b/packages/config-loader/src/lib/secrets.ts index 348a41db58..4c3812d527 100644 --- a/packages/config-loader/src/lib/secrets.ts +++ b/packages/config-loader/src/lib/secrets.ts @@ -38,9 +38,8 @@ type EnvSecret = { type DataSecret = { // Path to the data secret file, relative to the config file. data: string; - // The path to the value inside the data file. - // Either a '.' separated list, or an array of path segments. - path: string | string[]; + // The path to the value inside the data file, each element separated by '.'. + path?: string; }; type Secret = FileSecret | EnvSecret | DataSecret; @@ -55,12 +54,6 @@ const secretLoaderSchemas = { }), data: yup.object({ data: yup.string().required(), - path: yup.lazy(value => { - if (typeof value === 'string') { - return yup.string().required(); - } - return yup.array().of(yup.string().required()).required(); - }), }), }; @@ -111,24 +104,30 @@ export async function readSecret( return ctx.env[secret.env]; } if ('data' in secret) { - const ext = extname(secret.data); + const url = + 'path' in secret ? `${secret.data}#${secret.path}` : secret.data; + const [filePath, dataPath] = url.split(/#(.*)/); + if (!dataPath) { + throw new Error( + `Invalid format for data secret value, must be of the form #, got '${url}'`, + ); + } + + const ext = extname(filePath); const parser = dataSecretParser[ext]; if (!parser) { throw new Error(`No data secret parser available for extension ${ext}`); } - const content = await ctx.readFile(secret.data); + const content = await ctx.readFile(filePath); - const { path } = secret; - const parts = typeof path === 'string' ? path.split('.') : path; + const parts = dataPath.split('.'); let value: JsonValue | undefined = await parser(content); for (const [index, part] of parts.entries()) { if (!isObject(value)) { const errPath = parts.slice(0, index).join('.'); - throw new Error( - `Value is not an object at ${errPath} in ${secret.data}`, - ); + throw new Error(`Value is not an object at ${errPath} in ${filePath}`); } value = value[part]; } diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index a7547ee8fb..087e2309c0 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -24,8 +24,7 @@ describe('loadConfig', () => { app: title: Example App sessionKey: - $secret: - file: secrets/session-key.txt + $file: secrets/session-key.txt `, '/root/app-config.development.yaml': ` app: diff --git a/packages/config/package.json b/packages/config/package.json index 0b61e50ac6..d3695ebf33 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.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public", diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 72d0748183..c925471695 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.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@backstage/config": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", @@ -41,8 +41,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/test-utils-core": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/test-utils-core": "^0.1.1-alpha.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts index 13f4cd24bc..2684422b1e 100644 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -46,9 +46,9 @@ export type IdentityApi = { // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. /** - * Log out the current user + * Sign out the current user */ - logout(): Promise; + signOut(): Promise; }; export const identityApiRef = createApiRef({ diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 1ff8c46dad..109f15b26e 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -90,11 +90,6 @@ export type OAuthApi = { scope?: OAuthScope, options?: AuthRequestOptions, ): Promise; - - /** - * Log out the user's session. This will reload the page. - */ - logout(): Promise; }; /** @@ -114,11 +109,6 @@ export type OpenIdConnectApi = { * The returned promise can be rejected, but only if the user rejects the login request. */ getIdToken(options?: AuthRequestOptions): Promise; - - /** - * Log out the user's session. This will reload the page. - */ - logout(): Promise; }; /** @@ -187,7 +177,7 @@ export type ProfileInfo = { }; /** - * Session state values passed to subscribers of the SessionStateApi. + * Session state values passed to subscribers of the SessionApi. */ export enum SessionState { SignedIn = 'SignedIn', @@ -195,10 +185,22 @@ export enum SessionState { } /** - * This API provides access to an sessionState$ observable which provides an update when the - * user performs a sign in or sign out from an auth provider. + * The SessionApi provides basic controls for any auth provider that is tied to a persistent session. */ -export type SessionStateApi = { +export type SessionApi = { + /** + * Sign in with a minimum set of permissions. + */ + signIn(): Promise; + + /** + * Sign out from the current session. This will reload the page. + */ + signOut(): Promise; + + /** + * Observe the current state of the auth session. Emits the current state on subscription. + */ sessionState$(): Observable; }; @@ -215,7 +217,7 @@ export const googleAuthApiRef = createApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionStateApi + SessionApi >({ id: 'core.auth.google', description: 'Provides authentication towards Google APIs and identities', @@ -228,7 +230,7 @@ export const googleAuthApiRef = createApiRef< * for a full list of supported scopes. */ export const githubAuthApiRef = createApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >({ id: 'core.auth.github', description: 'Provides authentication towards GitHub APIs', @@ -245,7 +247,7 @@ export const oktaAuthApiRef = createApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionStateApi + SessionApi >({ id: 'core.auth.okta', description: 'Provides authentication towards Okta APIs', @@ -258,7 +260,7 @@ export const oktaAuthApiRef = createApiRef< * for a full list of supported scopes. */ export const gitlabAuthApiRef = createApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >({ id: 'core.auth.gitlab', description: 'Provides authentication towards GitLab APIs', @@ -271,7 +273,7 @@ export const gitlabAuthApiRef = createApiRef< * for a full list of supported scopes. */ export const auth0AuthApiRef = createApiRef< - OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi + OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >({ id: 'core.auth.auth0', description: 'Provides authentication towards Auth0 APIs', @@ -289,7 +291,7 @@ export const microsoftAuthApiRef = createApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & - SessionStateApi + SessionApi >({ id: 'core.auth.microsoft', description: 'Provides authentication towards Microsoft APIs and identities', @@ -302,9 +304,19 @@ export const oauth2ApiRef = createApiRef< OAuthApi & OpenIdConnectApi & ProfileInfoApi & - SessionStateApi & - BackstageIdentityApi + BackstageIdentityApi & + SessionApi >({ id: 'core.auth.oauth2', description: 'Example of how to use oauth2 custom provider', }); + +/** + * Provides authentication for saml based identity providers + */ +export const samlAuthApiRef = createApiRef< + ProfileInfoApi & BackstageIdentityApi & SessionApi +>({ + id: 'core.auth.saml', + description: 'Example of how to use SAML custom provider', +}); 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 8b9f807cd8..1d87ff3720 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -19,7 +19,7 @@ import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { GithubSession } from './types'; import { OAuthApi, - SessionStateApi, + SessionApi, SessionState, ProfileInfo, BackstageIdentity, @@ -61,7 +61,7 @@ const DEFAULT_PROVIDER = { icon: GithubIcon, }; -class GithubAuth implements OAuthApi, SessionStateApi { +class GithubAuth implements OAuthApi, SessionApi { static create({ discoveryApi, environment = 'development', @@ -102,12 +102,20 @@ class GithubAuth implements OAuthApi, SessionStateApi { return new GithubAuth(authSessionStore); } + constructor(private readonly sessionManager: SessionManager) {} + + async signIn() { + await this.getAccessToken(); + } + + async signOut() { + await this.sessionManager.removeSession(); + } + sessionState$(): Observable { return this.sessionManager.sessionState$(); } - constructor(private readonly sessionManager: SessionManager) {} - async getAccessToken(scope?: string, options?: AuthRequestOptions) { const session = await this.sessionManager.getSession({ ...options, @@ -128,10 +136,6 @@ class GithubAuth implements OAuthApi, SessionStateApi { return session?.profile; } - async logout() { - await this.sessionManager.removeSession(); - } - static normalizeScope(scope?: string): Set { if (!scope) { return new Set(); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts index 44b9aeb14e..6a592c7fbe 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts @@ -39,12 +39,12 @@ describe('GitlabAuth', () => { ], ['read_repository sudo', ['read_repository', 'sudo']], ])(`should normalize scopes correctly - %p`, (scope, scopes) => { - const googleAuth = GitlabAuth.create({ + const gitlabAuth = GitlabAuth.create({ oauthRequestApi: new MockOAuthApi(), discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); - googleAuth.getAccessToken(scope); + gitlabAuth.getAccessToken(scope); expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); }); }); diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index a6d7e2c989..39223e8e15 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -19,5 +19,6 @@ export * from './gitlab'; export * from './google'; export * from './oauth2'; export * from './okta'; +export * from './saml'; export * from './auth0'; export * from './microsoft'; 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 27626aac5a..d088ca9798 100644 --- a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -32,7 +32,7 @@ import { ProfileInfo, ProfileInfoApi, SessionState, - SessionStateApi, + SessionApi, BackstageIdentityApi, } from '../../../definitions/auth'; import { OAuth2Session } from './types'; @@ -75,7 +75,7 @@ class OAuth2 OpenIdConnectApi, ProfileInfoApi, BackstageIdentityApi, - SessionStateApi { + SessionApi { static create({ discoveryApi, environment = 'development', @@ -129,6 +129,14 @@ class OAuth2 this.scopeTransform = options.scopeTransform; } + async signIn() { + await this.getAccessToken(); + } + + async signOut() { + await this.sessionManager.removeSession(); + } + sessionState$(): Observable { return this.sessionManager.sessionState$(); } @@ -150,10 +158,6 @@ class OAuth2 return session?.providerInfo.idToken ?? ''; } - async logout() { - await this.sessionManager.removeSession(); - } - async getBackstageIdentity( options: AuthRequestOptions = {}, ): Promise { diff --git a/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.ts new file mode 100644 index 0000000000..973b402756 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/saml/SamlAuth.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 SamlIcon from '@material-ui/icons/AcUnit'; +import { DirectAuthConnector } from '../../../../lib/AuthConnector'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { Observable } from '../../../../types'; +import { + ProfileInfo, + BackstageIdentity, + SessionState, + AuthRequestOptions, + ProfileInfoApi, + 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 }; +}; + +export type SamlAuthResponse = { + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'saml', + title: 'SAML', + icon: SamlIcon, +}; + +class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + }: CreateOptions) { + const connector = new DirectAuthConnector({ + discoveryApi, + environment, + provider, + }); + + const sessionManager = new StaticAuthSessionManager({ + connector, + }); + + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: 'samlSession', + }); + + return new SamlAuth(authSessionStore); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async signIn() { + await this.getBackstageIdentity({}); + } + async signOut() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } +} + +export default SamlAuth; diff --git a/packages/core-api/src/apis/implementations/auth/saml/index.ts b/packages/core-api/src/apis/implementations/auth/saml/index.ts new file mode 100644 index 0000000000..c2436ab435 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/saml/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 { default as SamlAuth } from './SamlAuth'; diff --git a/plugins/identity-backend/src/adapters/userGroups.ts b/packages/core-api/src/apis/implementations/auth/saml/types.ts similarity index 59% rename from plugins/identity-backend/src/adapters/userGroups.ts rename to packages/core-api/src/apis/implementations/auth/saml/types.ts index 806ca1f43f..296f70b0ea 100644 --- a/plugins/identity-backend/src/adapters/userGroups.ts +++ b/packages/core-api/src/apis/implementations/auth/saml/types.ts @@ -13,23 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export const userGroups = { - groups: [ - { - name: 'engineering', - type: 'org', - children: [ - { - name: 'authentication', - type: 'team', - members: [{ name: 'kent' }, { name: 'dobbs' }], - }, - { - name: 'checkout', - type: 'team', - members: [{ name: 'don' }, { name: 'abramev' }], - }, - ], - }, - ], +import { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type SamlSession = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; }; diff --git a/packages/core-api/src/app/AppIdentity.ts b/packages/core-api/src/app/AppIdentity.ts index 69ee5d28ac..d3e5fe567a 100644 --- a/packages/core-api/src/app/AppIdentity.ts +++ b/packages/core-api/src/app/AppIdentity.ts @@ -26,7 +26,7 @@ export class AppIdentity implements IdentityApi { private userId?: string; private profile?: ProfileInfo; private idTokenFunc?: () => Promise; - private logoutFunc?: () => Promise; + private signOutFunc?: () => Promise; getUserId(): string { if (!this.hasIdentity) { @@ -55,13 +55,13 @@ export class AppIdentity implements IdentityApi { return this.idTokenFunc?.(); } - async logout(): Promise { + async signOut(): Promise { if (!this.hasIdentity) { throw new Error( - 'Tried to access IdentityApi logoutFunc before app was loaded', + 'Tried to access IdentityApi signOutFunc before app was loaded', ); } - await this.logoutFunc?.(); + await this.signOutFunc?.(); location.reload(); } @@ -80,6 +80,6 @@ export class AppIdentity implements IdentityApi { this.userId = result.userId; this.profile = result.profile; this.idTokenFunc = result.getIdToken; - this.logoutFunc = result.logout; + this.signOutFunc = result.signOut; } } diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 565e073aed..882ecd3d8b 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -38,10 +38,11 @@ export type SignInResult = { * Function used to retrieve an ID token for the signed in user. */ getIdToken?: () => Promise; + /** - * Logout handler that will be called if the user requests a logout. + * Sign out handler that will be called if the user requests to sign out. */ - logout?: () => Promise; + signOut?: () => Promise; }; export type SignInPageProps = { diff --git a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts new file mode 100644 index 0000000000..517bf82ae7 --- /dev/null +++ b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + AuthProvider, + ProfileInfo, + BackstageIdentity, + DiscoveryApi, +} from '../../apis/definitions'; +import { showLoginPopup } from '../loginPopup'; + +type Options = { + discoveryApi: DiscoveryApi; + environment?: string; + provider: AuthProvider & { id: string }; +}; + +export type DirectAuthResponse = { + userId: string; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +export class DirectAuthConnector { + private readonly discoveryApi: DiscoveryApi; + private readonly environment: string | undefined; + private readonly provider: AuthProvider & { id: string }; + + constructor(options: Options) { + const { discoveryApi, environment, provider } = options; + + this.discoveryApi = discoveryApi; + this.environment = environment; + this.provider = provider; + } + + async createSession(): Promise { + const popupUrl = await this.buildUrl('/start'); + const payload = await showLoginPopup({ + url: popupUrl, + name: `${this.provider.title} Login`, + origin: new URL(popupUrl).origin, + width: 450, + height: 730, + }); + + return { + ...payload, + id: payload.profile.email, + }; + } + + async refreshSession(): Promise {} + + async removeSession(): Promise { + const res = await fetch(await this.buildUrl('/logout'), { + method: 'POST', + headers: { + 'x-requested-with': 'XMLHttpRequest', + }, + credentials: 'include', + }).catch(error => { + throw new Error(`Logout request failed, ${error}`); + }); + + if (!res.ok) { + const error: any = new Error(`Logout request failed, ${res.statusText}`); + error.status = res.status; + throw error; + } + } + + private async buildUrl(path: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('auth'); + return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`; + } +} diff --git a/packages/core-api/src/lib/AuthConnector/index.ts b/packages/core-api/src/lib/AuthConnector/index.ts index db5c582328..388619e2c1 100644 --- a/packages/core-api/src/lib/AuthConnector/index.ts +++ b/packages/core-api/src/lib/AuthConnector/index.ts @@ -15,4 +15,5 @@ */ export { DefaultAuthConnector } from './DefaultAuthConnector'; +export { DirectAuthConnector } from './DirectAuthConnector'; export * from './types'; diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts index f2b8558f74..e82557b1ce 100644 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -28,7 +28,7 @@ type Options = { /** Storage key to use to store sessions */ storageKey: string; /** Used to get the scope of the session */ - sessionScopes: SessionScopesFunc; + sessionScopes?: SessionScopesFunc; /** Used to check if the session needs to be refreshed, defaults to never refresh */ sessionShouldRefresh?: SessionShouldRefreshFunc; }; diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts index e59c11421c..e02b600828 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -23,7 +23,7 @@ type Options = { /** The connector used for acting on the auth session */ connector: AuthConnector; /** Used to get the scope of the session */ - sessionScopes: (session: T) => Set; + sessionScopes?: (session: T) => Set; /** The default scopes that should always be present in a session, defaults to none. */ defaultScopes?: Set; }; diff --git a/packages/core-api/src/lib/AuthSessionManager/common.ts b/packages/core-api/src/lib/AuthSessionManager/common.ts index 83b81bc81a..ff2897535d 100644 --- a/packages/core-api/src/lib/AuthSessionManager/common.ts +++ b/packages/core-api/src/lib/AuthSessionManager/common.ts @@ -29,7 +29,7 @@ export function hasScopes( } type ScopeHelperOptions = { - sessionScopes: SessionScopesFunc; + sessionScopes: SessionScopesFunc | undefined; defaultScopes?: Set; }; @@ -46,13 +46,16 @@ export class SessionScopeHelper { if (!scopes) { return true; } + if (this.options.sessionScopes === undefined) { + return true; + } const sessionScopes = this.options.sessionScopes(session); return hasScopes(sessionScopes, scopes); } getExtendedScope(session: T | undefined, scopes?: Set) { const newScope = new Set(this.options.defaultScopes); - if (session) { + if (session && this.options.sessionScopes !== undefined) { const sessionScopes = this.options.sessionScopes(session); for (const scope of sessionScopes) { newScope.add(scope); diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 9f9980ed0c..c33335cb38 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,30 +14,78 @@ * limitations under the License. */ -import type { RouteRefConfig, RouteRefOverrideConfig } from './types'; +import { + ConcreteRoute, + routeReference, + ReferencedRoute, + resolveRoute, + RouteRefConfig, +} from './types'; +import { generatePath } from 'react-router-dom'; -export class MutableRouteRef { - private effectiveConfig: RouteRefConfig = this.config; +type SubRouteConfig = { + path: string; +}; +export class SubRouteRef + implements ReferencedRoute { + constructor( + private readonly parent: ConcreteRoute, + private readonly config: SubRouteConfig, + ) {} + + get [routeReference]() { + return this; + } + + link(...args: Args): ConcreteRoute { + return { + [routeReference]: this, + [resolveRoute]: (path: string) => { + const ownPart = generatePath(this.config.path, args[0] ?? {}); + const parentPart = this.parent[resolveRoute](path); + return parentPart + ownPart; + }, + }; + } +} + +export class AbsoluteRouteRef implements ConcreteRoute { constructor(private readonly config: RouteRefConfig) {} - override(overrideConfig: RouteRefOverrideConfig) { - this.effectiveConfig = { ...this.config, ...overrideConfig }; - } - get icon() { - return this.effectiveConfig.icon; + return this.config.icon; } + // TODO(Rugvip): Remove this, routes are looked up via the registry instead get path() { - return this.effectiveConfig.path; + return this.config.path; } get title() { - return this.effectiveConfig.title; + return this.config.title; + } + + createSubRoute( + config: SubRouteConfig, + ) { + return new SubRouteRef(this, config); + } + + get [routeReference]() { + return this; + } + + [resolveRoute](path: string) { + return path; } } -export function createRouteRef(config: RouteRefConfig): MutableRouteRef { - return new MutableRouteRef(config); +export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { + return new AbsoluteRouteRef(config); } + +// TODO(Rugvip): Added for backwards compatibility, remove once old usage is gone +// We may want to avoid exporting the AbsoluteRouteRef itself though, and consider +// a different model for how to create sub routes, just avoid this +export type MutableRouteRef = AbsoluteRouteRef; diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts new file mode 100644 index 0000000000..fa1ef584f1 --- /dev/null +++ b/packages/core-api/src/routing/RouteRefRegistry.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 { RouteRefRegistry } from './RouteRefRegistry'; +import { createRouteRef } from './RouteRef'; + +const dummyConfig = { path: '/', icon: () => null, title: 'my-title' }; +const ref1 = createRouteRef(dummyConfig); +const ref11 = createRouteRef(dummyConfig); +const ref12 = createRouteRef(dummyConfig); +const ref121 = createRouteRef(dummyConfig); +const ref2 = createRouteRef(dummyConfig); +const ref2a = ref2.createSubRoute({ path: '/a' }); +const ref2b = ref2.createSubRoute<{ id: string }>({ path: '/b/:id' }); + +describe('RouteRefRegistry', () => { + it('should be constructed with a root route', () => { + const registry = new RouteRefRegistry(); + expect(registry.resolveRoute([], [])).toBe(''); + }); + + it('should register and resolve some absolute routes', () => { + const registry = new RouteRefRegistry(); + expect(registry.registerRoute([ref1], '1')).toBe(true); + expect(registry.registerRoute([ref1, ref11], '11')).toBe(true); + expect(registry.registerRoute([ref1, ref12], '12')).toBe(true); + expect(registry.registerRoute([ref1, ref12, ref121], '121')).toBe(true); + expect(registry.registerRoute([ref1, ref12, ref121], 'duplicate')).toBe( + false, + ); + expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false); + expect(registry.registerRoute([ref2], '2')).toBe(true); + expect(registry.registerRoute([ref2], 'duplicate')).toBe(false); + expect(registry.registerRoute([ref2], '2')).toBe(true); + + expect(registry.resolveRoute([], [ref1])).toBe('/1'); + expect(registry.resolveRoute([], [ref11])).toBe(undefined); + expect(registry.resolveRoute([], [ref1, ref11])).toBe('/1/11'); + expect(registry.resolveRoute([ref1], [ref11])).toBe('/1/11'); + expect(registry.resolveRoute([ref1], [ref2])).toBe('/2'); + expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('/1/12/121'); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe( + '/1/12/121', + ); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe( + '/1/12/121', + ); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('/1/12'); + expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1'); + }); + + it('should register and resolve with sub routes', () => { + const registry = new RouteRefRegistry(); + expect(registry.registerRoute([ref1], '1')).toBe(true); + expect(registry.registerRoute([ref2], '2')).toBe(true); + expect(registry.registerRoute([ref2a], '2')).toBe(true); + expect(registry.registerRoute([ref2a, ref1], '1')).toBe(true); + expect(registry.registerRoute([ref2a, ref2], '2')).toBe(true); + expect(registry.registerRoute([ref2b], '2')).toBe(true); + expect(registry.registerRoute([ref2b, ref1], '1')).toBe(true); + expect(registry.registerRoute([ref2b, ref2], '2')).toBe(true); + + expect(registry.resolveRoute([], [ref1])).toBe('/1'); + expect(registry.resolveRoute([], [ref2])).toBe('/2'); + expect(registry.resolveRoute([], [ref2a.link(), ref1])).toBe('/2/a/1'); + expect(registry.resolveRoute([], [ref2a.link(), ref2])).toBe('/2/a/2'); + expect(registry.resolveRoute([ref2a.link()], [ref2])).toBe('/2/a/2'); + expect(registry.resolveRoute([ref2a.link(), ref1], [ref2])).toBe('/2/a/2'); + expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe( + '/2/b/abc/1', + ); + expect(registry.resolveRoute([], [ref2b.link({ id: 'xyz' }), ref2])).toBe( + '/2/b/xyz/2', + ); + expect(registry.resolveRoute([ref2b.link({ id: 'abc' })], [ref2])).toBe( + '/2/b/abc/2', + ); + expect( + registry.resolveRoute([ref2b.link({ id: 'abc' }), ref1], [ref2]), + ).toBe('/2/b/abc/2'); + }); + + it('should throw when registering routes incorrectly', () => { + const registry = new RouteRefRegistry(); + expect(() => { + registry.registerRoute([ref1, ref11], '11'); + }).toThrow('Could not find parent for new routing node'); + expect(() => { + registry.registerRoute([], '11'); + }).toThrow('Must provide at least 1 route to add routing node'); + }); +}); diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts new file mode 100644 index 0000000000..7e55cbe8f7 --- /dev/null +++ b/packages/core-api/src/routing/RouteRefRegistry.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ConcreteRoute, + routeReference, + resolveRoute, + ReferencedRoute, +} from './types'; + +const rootRoute: ConcreteRoute = { + get [routeReference]() { + return this; + }, + [resolveRoute]: () => '', +}; + +export type RouteRefResolver = { + resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string; +}; + +class Node { + readonly children = new Map(); + + constructor(readonly path: string, readonly parent: Node | undefined) {} + + /** + * Look up a node in the tree given a path. + */ + findNode(routes: ReferencedRoute[]): Node | undefined { + let node = this as Node | undefined; + + for (let i = 0; i < routes.length; i++) { + node = node?.children.get(routes[i][routeReference]); + } + + return node; + } + + /** + * Assigns a path to a leaf node in the routing tree. All ancestor + * nodes of the new leaf node must already exist, or an error will be thrown. + * + * Returns true if the node was added, or false if the node already existed. + */ + addNode(routes: ReferencedRoute[], path: string): boolean { + if (routes.length === 0) { + throw new Error('Must provide at least 1 route to add routing node'); + } + + const parentNode = this.findNode(routes.slice(0, -1)); + if (!parentNode) { + throw new Error('Could not find parent for new routing node'); + } + + const lastRoute = routes[routes.length - 1]; + const lastRouteRef = lastRoute[routeReference]; + + const existingNode = parentNode.children.get(lastRouteRef); + if (existingNode) { + return existingNode.path === path; + } + + parentNode.children.set(lastRouteRef, new Node(path, parentNode)); + return true; + } + + /** + * Resolve an absolute URL that represents this node in the routing tree, using + * using the supplied concrete routes and ancestors of this node. + * + * The length of the provided routes array must match the depth of + * the routing tree that this node is at, or an error will be thrown. + */ + resolve(routes: ConcreteRoute[]) { + const parts = Array(routes.length); + + let node = this as Node | undefined; + for (let i = routes.length - 1; i >= 0; i--) { + if (!node) { + throw new Error('Route resolve missing required parent'); + } + + const route = routes[i]; + parts[i] = route[resolveRoute](node.path); + + node = node.parent; + } + + if (node) { + throw new Error('Route resolve did not reach root'); + } + + return parts.join('/'); + } +} + +/** + * A registry for resolving route refs into concrete string routes. + */ +export class RouteRefRegistry { + private readonly root = new Node('', undefined); + + /** + * Register a new leaf path for a sequence of routes. All ancestor + * routes must already exist. + */ + registerRoute(routes: ReferencedRoute[], path: string): boolean { + return this.root.addNode(routes, path); + } + + /** + * Resolve an absolute path from a point in the routing tree. + * + * The route referenced by `from` must exist, and is the starting + * point for the search, walking up the tree until a subtree that + * matches the routes reference in `to` are found. + * + * If `from` is empty, the search starts and ends at the root node. + * If `to` is empty, the route referenced by `from` will always be returned. + */ + resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string | undefined { + // Keep track of the `from` routes and pop the last ones as we traverse up + // the routing tree. The list of concrete routes that we're passing to + // `node.resolve()` should only include the ones in the resolve path. + const concreteStack = from.slice(); + + let fromNode = this.root.findNode(from); + while (fromNode) { + const resolvedNode = fromNode.findNode(to); + if (resolvedNode) { + return resolvedNode.resolve([rootRoute].concat(concreteStack, to)); + } + + // Search at this level of the tree failed, move up to parent + concreteStack.pop(); + fromNode = fromNode.parent; + } + + return undefined; + } +} diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 67d4c82167..29de34ec42 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export * from './types'; +export type { RouteRef, RouteRefConfig, ConcreteRoute } from './types'; +export type { MutableRouteRef, AbsoluteRouteRef } from './RouteRef'; export { createRouteRef } from './RouteRef'; -export type { MutableRouteRef } from './RouteRef'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 515dc31de6..162ac74bde 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -16,7 +16,19 @@ import { IconComponent } from '../icons'; +export const resolveRoute = Symbol('resolve-route'); +export const routeReference = Symbol('route-ref'); + +export type ReferencedRoute = { + [routeReference]: unknown; +}; + +export type ConcreteRoute = ReferencedRoute & { + [resolveRoute](path: string): string; +}; + export type RouteRef = { + // TODO(Rugvip): Remove path, look up via registry instead path: string; icon?: IconComponent; title: string; @@ -27,9 +39,3 @@ export type RouteRefConfig = { icon?: IconComponent; title: string; }; - -export type RouteRefOverrideConfig = { - path?: string; - icon?: IconComponent; - title?: string; -}; diff --git a/packages/core/package.json b/packages/core/package.json index 3ed730e0f3..0a0dc7576c 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.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.22", - "@backstage/core-api": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@backstage/config": "^0.1.1-alpha.24", + "@backstage/core-api": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -54,8 +54,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/test-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/test-utils": "^0.1.1-alpha.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts index a3f0cb0251..dedadf0456 100644 --- a/packages/core/src/api-wrappers/defaultApis.ts +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -44,6 +44,8 @@ import { createApiFactory, configApiRef, UrlPatternDiscovery, + samlAuthApiRef, + SamlAuth, } from '@backstage/core-api'; export const defaultApis = [ @@ -132,4 +134,11 @@ export const defaultApis = [ factory: ({ discoveryApi, oauthRequestApi }) => OAuth2.create({ discoveryApi, oauthRequestApi }), }), + createApiFactory({ + api: samlAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + }, + factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }), + }), ]; diff --git a/packages/core/src/components/CodeSnippet/CodeSnippet.tsx b/packages/core/src/components/CodeSnippet/CodeSnippet.tsx index b6d9b3a5aa..84b5aa403e 100644 --- a/packages/core/src/components/CodeSnippet/CodeSnippet.tsx +++ b/packages/core/src/components/CodeSnippet/CodeSnippet.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; import SyntaxHighlighter from 'react-syntax-highlighter'; import { docco, dark } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; import { useTheme } from '@material-ui/core'; @@ -27,28 +26,39 @@ type Props = { language: string; showLineNumbers?: boolean; showCopyCodeButton?: boolean; + highlightedNumbers?: number[]; + customStyle?: any; }; -const defaultProps = { - showLineNumbers: false, - showCopyCodeButton: false, -}; - -export const CodeSnippet: FC = props => { - const { text, language, showLineNumbers, showCopyCodeButton } = { - ...defaultProps, - ...props, - }; - +export const CodeSnippet = ({ + text, + language, + showLineNumbers = false, + showCopyCodeButton = false, + highlightedNumbers, + customStyle, +}: Props) => { const theme = useTheme(); const mode = theme.palette.type === 'dark' ? dark : docco; - + const highlightColor = theme.palette.type === 'dark' ? '#256bf3' : '#e6ffed'; return (
+ highlightedNumbers?.includes(lineNumber) + ? { + style: { + backgroundColor: highlightColor, + }, + } + : {} + } > {text} @@ -60,11 +70,3 @@ export const CodeSnippet: FC = props => {
); }; - -// Type check for the JS files using this core component -CodeSnippet.propTypes = { - text: PropTypes.string.isRequired, - language: PropTypes.string.isRequired, - showLineNumbers: PropTypes.bool, - showCopyCodeButton: PropTypes.bool, -}; diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx index 62760f72fb..148e7b6803 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx @@ -100,3 +100,15 @@ export const WithLink = () => (
); +export const Fixed = () => ( +
+ + + +
+); diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx index 747ed06b37..568ab6306b 100644 --- a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, ReactNode, useState, useEffect } from 'react'; +import React, { ReactNode, useState, useEffect } from 'react'; import { useApi, storageApiRef } from '@backstage/core-api'; import { useObservable } from 'react-use'; import classNames from 'classnames'; @@ -27,12 +27,17 @@ import Close from '@material-ui/icons/Close'; const useStyles = makeStyles((theme: BackstageTheme) => ({ root: { - position: 'relative', padding: theme.spacing(0), - marginBottom: theme.spacing(6), - marginTop: -theme.spacing(3), + marginBottom: theme.spacing(0), + marginTop: theme.spacing(0), display: 'flex', flexFlow: 'row nowrap', + }, + // showing on top + topPosition: { + position: 'relative', + marginBottom: theme.spacing(6), + marginTop: -theme.spacing(3), zIndex: 'unset', }, icon: { @@ -45,6 +50,10 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({ message: { display: 'flex', alignItems: 'center', + color: theme.palette.banner.text, + '& a': { + color: theme.palette.banner.link, + }, }, info: { backgroundColor: theme.palette.banner.info, @@ -58,9 +67,15 @@ type Props = { variant: 'info' | 'error'; message: ReactNode; id: string; + fixed?: boolean; }; -export const DismissableBanner: FC = ({ variant, message, id }) => { +export const DismissableBanner = ({ + variant, + message, + id, + fixed = false, +}: Props) => { const classes = useStyles(); const storageApi = useApi(storageApiRef); const notificationsStore = storageApi.forBucket('notifications'); @@ -88,9 +103,15 @@ export const DismissableBanner: FC = ({ variant, message, id }) => { return ( ( +
+ +
+); + +export const Info = () => ( +
+ +
+); + +export const Content = () => ( +
+ +
+); + +export const Data = () => ( +
+ +
+); + +export const WithAction = () => ( +
+ {}} variant="contained"> + DOCS + + } + /> +
+); + +export const MissingAnnotation = () => ( +
+ +
+); diff --git a/packages/core/src/components/EmptyState/EmptyState.test.tsx b/packages/core/src/components/EmptyState/EmptyState.test.tsx new file mode 100644 index 0000000000..c5bc7a9876 --- /dev/null +++ b/packages/core/src/components/EmptyState/EmptyState.test.tsx @@ -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 React from 'react'; +import { EmptyState } from './EmptyState'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { Button } from '@material-ui/core'; + +describe('', () => { + it('render EmptyState component with type annotaion is missing', async () => { + const rendered = await renderWithEffects( + wrapInTestApp( + DOCS} + />, + ), + ); + expect( + rendered.getByText('Your plugin is missing an annotation'), + ).toBeInTheDocument(); + expect(rendered.getByLabelText('button')).toBeInTheDocument(); + expect(rendered.getByTestId('missingAnnotation')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/EmptyState/EmptyState.tsx b/packages/core/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 0000000000..4469c3ce86 --- /dev/null +++ b/packages/core/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,78 @@ +/* + * 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, Grid } from '@material-ui/core'; +import { EmptyStateImage } from './EmptyStateImage'; +import Background from './assets/Background.svg'; + +const useStyles = makeStyles(theme => ({ + root: { + backgroundColor: theme.palette.background.default, + padding: theme.spacing(2, 0, 0, 0), + }, + action: { + marginTop: theme.spacing(2), + }, + imageContainer: { + position: 'relative', + }, + backgroundImage: { + position: 'absolute', + width: '100%', + }, +})); + +type Props = { + title: string; + description?: string; + missing: 'field' | 'info' | 'content' | 'data'; + action?: JSX.Element; +}; + +export const EmptyState = ({ title, description, missing, action }: Props) => { + const classes = useStyles(); + return ( + + + + {title} + + + {description} + + + {action} + + + + background + + + + ); +}; diff --git a/packages/core/src/components/EmptyState/EmptyStateImage.tsx b/packages/core/src/components/EmptyState/EmptyStateImage.tsx new file mode 100644 index 0000000000..49598ae6db --- /dev/null +++ b/packages/core/src/components/EmptyState/EmptyStateImage.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import missingAnnotation from './assets/missingAnnotation.svg'; +import noInformation from './assets/noInformation.svg'; +import createComponent from './assets/createComponent.svg'; +import noBuild from './assets/noBuild.svg'; +import { makeStyles } from '@material-ui/core'; + +type Props = { + missing: 'field' | 'info' | 'content' | 'data'; +}; + +const useStyles = makeStyles({ + generalImg: { + width: '75%', + zIndex: 2, + position: 'absolute', + left: '50%', + top: '50%', + transform: 'translate(-50%, 15%)', + }, +}); + +export const EmptyStateImage = ({ missing }: Props) => { + const classes = useStyles(); + switch (missing) { + case 'field': + return ( + annotation is missing + ); + case 'info': + return ( + no Information + ); + case 'content': + return ( + create Component + ); + case 'data': + return ( + no Build + ); + default: + return null; + } +}; diff --git a/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.tsx new file mode 100644 index 0000000000..d8847fdf47 --- /dev/null +++ b/packages/core/src/components/EmptyState/MissingAnnotationEmptyState.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 React from 'react'; +import { Button, makeStyles, Typography } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { EmptyState } from './EmptyState'; +import { CodeSnippet } from '../CodeSnippet'; + +const COMPONENT_YAML = `# Example +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example + description: example.com + annotations: + ANNOTATION: value +spec: + type: website + lifecycle: production + owner: guest +`; + +type Props = { + annotation: string; +}; + +const useStyles = makeStyles(theme => ({ + code: { + borderRadius: 6, + margin: `${theme.spacing(2)}px 0px`, + background: theme.palette.type === 'dark' ? '#444' : '#fff', + }, +})); + +export const MissingAnnotationEmptyState = ({ annotation }: Props) => { + const classes = useStyles(); + return ( + + + Add the annotation to your component YAML as shown in the + highlighted example below: + +
+ +
+ + + } + /> + ); +}; diff --git a/packages/core/src/components/EmptyState/assets/Background.svg b/packages/core/src/components/EmptyState/assets/Background.svg new file mode 100644 index 0000000000..ce9aae6739 --- /dev/null +++ b/packages/core/src/components/EmptyState/assets/Background.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/components/EmptyState/assets/createComponent.svg b/packages/core/src/components/EmptyState/assets/createComponent.svg new file mode 100644 index 0000000000..bb8b50e1ef --- /dev/null +++ b/packages/core/src/components/EmptyState/assets/createComponent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/components/EmptyState/assets/missingAnnotation.svg b/packages/core/src/components/EmptyState/assets/missingAnnotation.svg new file mode 100644 index 0000000000..39534656fa --- /dev/null +++ b/packages/core/src/components/EmptyState/assets/missingAnnotation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/components/EmptyState/assets/noBuild.svg b/packages/core/src/components/EmptyState/assets/noBuild.svg new file mode 100644 index 0000000000..ef45e1c736 --- /dev/null +++ b/packages/core/src/components/EmptyState/assets/noBuild.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/components/EmptyState/assets/noInformation.svg b/packages/core/src/components/EmptyState/assets/noInformation.svg new file mode 100644 index 0000000000..9a1c230e97 --- /dev/null +++ b/packages/core/src/components/EmptyState/assets/noInformation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/src/components/EmptyState/index.ts b/packages/core/src/components/EmptyState/index.ts new file mode 100644 index 0000000000..2e2a88be94 --- /dev/null +++ b/packages/core/src/components/EmptyState/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 { EmptyState } from './EmptyState'; +export { MissingAnnotationEmptyState } from './MissingAnnotationEmptyState'; diff --git a/packages/core/src/components/ProgressBars/Gauge.stories.tsx b/packages/core/src/components/ProgressBars/Gauge.stories.tsx new file mode 100644 index 0000000000..6599b0f768 --- /dev/null +++ b/packages/core/src/components/ProgressBars/Gauge.stories.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 { Gauge } from './Gauge'; + +const containerStyle = { width: 300 }; + +export default { + title: 'Gauge', + component: Gauge, +}; + +export const Default = () => ( +
+ +
+); + +export const MediumProgress = () => ( +
+ +
+); + +export const LowProgress = () => ( +
+ +
+); + +export const InverseLowProgress = () => ( +
+ +
+); + +export const AbsoluteProgress = () => ( +
+ +
+); diff --git a/packages/core/src/components/ProgressBars/GaugeProgress.test.jsx b/packages/core/src/components/ProgressBars/Gauge.test.jsx similarity index 82% rename from packages/core/src/components/ProgressBars/GaugeProgress.test.jsx rename to packages/core/src/components/ProgressBars/Gauge.test.jsx index 778abdf12c..709de7b0c4 100644 --- a/packages/core/src/components/ProgressBars/GaugeProgress.test.jsx +++ b/packages/core/src/components/ProgressBars/Gauge.test.jsx @@ -17,32 +17,32 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; -import { GaugeProgress, getProgressColor } from './GaugeProgress'; +import { Gauge, getProgressColor } from './Gauge'; -describe('', () => { +describe('', () => { it('renders without exploding', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('10%'); }); it('handles fractional prop', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('10%'); }); it('handles max prop', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('1%'); }); it('handles unit prop', () => { const { getByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); getByText('10m'); }); diff --git a/packages/core/src/components/ProgressBars/GaugeProgress.tsx b/packages/core/src/components/ProgressBars/Gauge.tsx similarity index 98% rename from packages/core/src/components/ProgressBars/GaugeProgress.tsx rename to packages/core/src/components/ProgressBars/Gauge.tsx index 14776ed431..4c339bf8e1 100644 --- a/packages/core/src/components/ProgressBars/GaugeProgress.tsx +++ b/packages/core/src/components/ProgressBars/Gauge.tsx @@ -77,7 +77,7 @@ export function getProgressColor( return palette.status.ok; } -export const GaugeProgress: FC = props => { +export const Gauge: FC = props => { const classes = useStyles(props); const theme = useTheme(); const { value, fractional, inverse, unit, max } = { diff --git a/packages/core/src/components/ProgressBars/GaugeCard.tsx b/packages/core/src/components/ProgressBars/GaugeCard.tsx index fc7055f705..4eb4b2e075 100644 --- a/packages/core/src/components/ProgressBars/GaugeCard.tsx +++ b/packages/core/src/components/ProgressBars/GaugeCard.tsx @@ -18,7 +18,7 @@ import React, { FC } from 'react'; import { makeStyles } from '@material-ui/core'; import { InfoCard } from '../../layout/InfoCard'; import { BottomLinkProps } from '../../layout/BottomLink'; -import { GaugeProgress } from './GaugeProgress'; +import { Gauge } from './Gauge'; type Props = { title: string; @@ -48,7 +48,7 @@ export const GaugeCard: FC = props => { deepLink={deepLink} variant={variant} > - + ); diff --git a/packages/core/src/components/ProgressBars/LinearGauge.tsx b/packages/core/src/components/ProgressBars/LinearGauge.tsx index 73163b345f..2b8e77838d 100644 --- a/packages/core/src/components/ProgressBars/LinearGauge.tsx +++ b/packages/core/src/components/ProgressBars/LinearGauge.tsx @@ -19,7 +19,7 @@ import { Tooltip, useTheme } from '@material-ui/core'; // @ts-ignore import { Line } from 'rc-progress'; import { BackstageTheme } from '@backstage/theme'; -import { getProgressColor } from './GaugeProgress'; +import { getProgressColor } from './Gauge'; type Props = { /** diff --git a/packages/core/src/components/ProgressBars/index.ts b/packages/core/src/components/ProgressBars/index.ts index c7131c8831..4463aea29b 100644 --- a/packages/core/src/components/ProgressBars/index.ts +++ b/packages/core/src/components/ProgressBars/index.ts @@ -15,5 +15,5 @@ */ export { GaugeCard } from './GaugeCard'; -export { GaugeProgress } from './GaugeProgress'; +export { Gauge } from './Gauge'; export { LinearGauge } from './LinearGauge'; diff --git a/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx b/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx index 1d863db764..b34e535a00 100644 --- a/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx +++ b/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx @@ -71,13 +71,7 @@ export const MetadataTable = ({ dense?: boolean; children: React.ReactNode; }) => ( -
- {!dense && ( - - - - - )} +
{children}
); diff --git a/packages/core/src/components/StructuredMetadataTable/README.md b/packages/core/src/components/StructuredMetadataTable/README.md index 452d32490f..60952e5e34 100644 --- a/packages/core/src/components/StructuredMetadataTable/README.md +++ b/packages/core/src/components/StructuredMetadataTable/README.md @@ -23,7 +23,7 @@ The component itself only handles the display area, so you can use standard JS t This will step through each of the keys and based on their types display them in a logical way. -### Primatives +### Primitives Any non complex value will be displayed using `{value}` which will just output the value as text. diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx index bdf0f65375..9db7385872 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx @@ -56,3 +56,16 @@ export const Default = () => ( ); + +export const NotDenseTable = () => ( + + +
+ +
+
+
+); diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx index f966bd570f..52ad9a320a 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { Component, Fragment, ReactElement } from 'react'; +import React, { Fragment, ReactElement } from 'react'; import { withStyles, createStyles, WithStyles, Theme } from '@material-ui/core'; import startCase from 'lodash/startCase'; @@ -142,17 +142,17 @@ const TableItem = ({ ); }; -interface ComponentProps { +type Props = { metadata: { [key: string]: any }; dense?: boolean; options?: any; -} +}; -export class StructuredMetadataTable extends Component { - render() { - const { metadata, dense, options } = this.props; - const metadataItems = mapToItems(metadata, options || {}); - - return {metadataItems}; - } -} +export const StructuredMetadataTable = ({ + metadata, + dense = true, + options, +}: Props) => { + const metadataItems = mapToItems(metadata, options || {}); + return {metadataItems}; +}; diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 6c070366c7..4f085ffd6a 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -34,3 +34,4 @@ export * from './Table'; export * from './Tabs'; export * from './TrendLine'; export * from './WarningPanel'; +export * from './EmptyState'; diff --git a/packages/core/src/layout/ErrorPage/mic-drop.svg b/packages/core/src/layout/ErrorPage/mic-drop.svg index adc027b551..5a7d7d8759 100644 --- a/packages/core/src/layout/ErrorPage/mic-drop.svg +++ b/packages/core/src/layout/ErrorPage/mic-drop.svg @@ -1,126 +1 @@ - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx index 7ea4dd508f..59b7f3fb3d 100644 --- a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx +++ b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx @@ -14,59 +14,76 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { HeaderLabel } from '../HeaderLabel'; +import { ConfigApi, useApi, configApiRef } from '@backstage/core-api'; const timeFormat = { hour: '2-digit', minute: '2-digit' }; -const utcOptions = { timeZone: 'UTC', ...timeFormat }; -const nycOptions = { timeZone: 'America/New_York', ...timeFormat }; -const tyoOptions = { timeZone: 'Asia/Tokyo', ...timeFormat }; -const stoOptions = { timeZone: 'Europe/Stockholm', ...timeFormat }; -const defaultTimes = { - timeNY: '', - timeUTC: '', - timeTYO: '', - timeSTO: '', +type TimeObj = { + time: string; + label: string; }; -function getTimes() { +function getTimes(configApi: ConfigApi) { const d = new Date(); const lang = window.navigator.language; - // Using the browser native toLocaleTimeString instead of huge moment-tz - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString - const timeNY = d.toLocaleTimeString(lang, nycOptions); - const timeUTC = d.toLocaleTimeString(lang, utcOptions); - const timeTYO = d.toLocaleTimeString(lang, tyoOptions); - const timeSTO = d.toLocaleTimeString(lang, stoOptions); + const clocks: TimeObj[] = []; - return { timeNY, timeUTC, timeTYO, timeSTO }; + if (!configApi.has('homepage.clocks')) { + return clocks; + } + + const clockConfigs = configApi.getConfigArray('homepage.clocks'); + + for (const clock of clockConfigs) { + if (clock.has('label') && clock.has('timezone')) { + const options = { + timeZone: clock.getString('timezone'), + ...timeFormat, + }; + + const time = d.toLocaleTimeString(lang, options); + const label = clock.getString('label'); + + clocks.push({ time, label }); + } + } + + return clocks; } -export const HomepageTimer: FC<{}> = () => { - const [{ timeNY, timeUTC, timeTYO, timeSTO }, setTimes] = React.useState( - defaultTimes, - ); +export const HomepageTimer = () => { + const configApi = useApi(configApiRef); + + const defaultTimes: TimeObj[] = []; + const [clocks, setTimes] = React.useState(defaultTimes); React.useEffect(() => { - setTimes(getTimes()); + setTimes(getTimes(configApi)); const intervalId = setInterval(() => { - setTimes(getTimes()); + setTimes(getTimes(configApi)); }, 1000); return () => { clearInterval(intervalId); }; - }, []); + }, [configApi]); - return ( - <> - - - - - - ); + if (clocks.length !== 0) { + return ( + <> + {clocks.map(clock => ( + + ))} + + ); + } + return null; }; diff --git a/packages/core/src/layout/InfoCard/InfoCard.tsx b/packages/core/src/layout/InfoCard/InfoCard.tsx index 6914d396b1..09d31735cd 100644 --- a/packages/core/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core/src/layout/InfoCard/InfoCard.tsx @@ -37,6 +37,7 @@ const useStyles = makeStyles(theme => ({ }, }, header: { + display: 'inline-block', padding: theme.spacing(2, 2, 2, 2.5), }, headerTitle: { @@ -202,7 +203,7 @@ export const InfoCard = ({ }} title={title} subheader={subheader} - style={{ display: 'inline-block', ...headerStyle }} + style={{ ...headerStyle }} {...headerProps} /> diff --git a/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx b/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx index 293cefb19a..99c5225c83 100644 --- a/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx +++ b/packages/core/src/layout/Sidebar/DefaultProviderSettings.tsx @@ -21,11 +21,12 @@ import { oauth2ApiRef, oktaAuthApiRef, microsoftAuthApiRef, + samlAuthApiRef, useApi, } from '@backstage/core-api'; import Star from '@material-ui/icons/Star'; import React from 'react'; -import { OAuthProviderSettings, OIDCProviderSettings } from './Settings'; +import { ProviderSettingsItem } from './Settings'; export const DefaultProviderSettings = () => { const configApi = useApi(configApiRef); @@ -35,42 +36,49 @@ export const DefaultProviderSettings = () => { return ( <> {providers.includes('google') && ( - )} {providers.includes('microsoft') && ( - )} {providers.includes('github') && ( - )} {providers.includes('gitlab') && ( - )} {providers.includes('okta') && ( - )} + {providers.includes('saml') && ( + + )} {providers.includes('oauth2') && ( - ; -}; - -export const OAuthProviderSettings: FC = ({ - title, - icon, - apiRef, -}) => { - const api = useApi(apiRef); - const [signedIn, setSignedIn] = useState(false); - - useEffect(() => { - let didCancel = false; - - const checkSession = async () => { - const session = await api.getAccessToken('', { optional: true }); - if (!didCancel) { - setSignedIn(!!session); - } - }; - let subscription: Subscription; - const observeSession = () => { - subscription = api - .sessionState$() - .subscribe((sessionState: SessionState) => { - if (!didCancel) { - setSignedIn(sessionState === SessionState.SignedIn); - } - }); - }; - - checkSession(); - observeSession(); - return () => { - didCancel = true; - subscription.unsubscribe(); - }; - }, [api]); - - return ( - api.getAccessToken()} - /> - ); -}; diff --git a/packages/core/src/layout/Sidebar/Settings/OIDCProviderSettings.tsx b/packages/core/src/layout/Sidebar/Settings/OIDCProviderSettings.tsx deleted file mode 100644 index 19ee00eee1..0000000000 --- a/packages/core/src/layout/Sidebar/Settings/OIDCProviderSettings.tsx +++ /dev/null @@ -1,81 +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 { - ApiRef, - OpenIdConnectApi, - SessionStateApi, - useApi, - Subscription, - IconComponent, - SessionState, -} from '@backstage/core-api'; -import React, { FC, useState, useEffect } from 'react'; -import { ProviderSettingsItem } from './ProviderSettingsItem'; - -export type OIDCProviderSidebarProps = { - title: string; - icon: IconComponent; - apiRef: ApiRef; -}; - -export const OIDCProviderSettings: FC = ({ - title, - icon, - apiRef, -}) => { - const api = useApi(apiRef); - const [signedIn, setSignedIn] = useState(false); - - useEffect(() => { - let didCancel = false; - - const checkSession = async () => { - const session = await api.getIdToken({ optional: true }); - if (!didCancel) { - setSignedIn(!!session); - } - }; - - let subscription: Subscription; - const observeSession = () => { - subscription = api - .sessionState$() - .subscribe((sessionState: SessionState) => { - if (!didCancel) { - setSignedIn(sessionState === SessionState.SignedIn); - } - }); - }; - - checkSession(); - observeSession(); - return () => { - didCancel = true; - subscription.unsubscribe(); - }; - }, [api]); - - return ( - api.getIdToken()} - /> - ); -}; diff --git a/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx b/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx index 653c64575b..4db7ea0823 100644 --- a/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx +++ b/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx @@ -14,8 +14,7 @@ * limitations under the License. */ -import React from 'react'; -import { IconComponent, OAuthApi, OpenIdConnectApi } from '@backstage/core-api'; +import React, { FC, useState, useEffect } from 'react'; import { ListItem, ListItemIcon, @@ -25,42 +24,67 @@ import { } from '@material-ui/core'; import PowerButton from '@material-ui/icons/PowerSettingsNew'; import { ToggleButton } from '@material-ui/lab'; +import { + ApiRef, + SessionApi, + useApi, + IconComponent, + SessionState, +} from '@backstage/core-api'; -type Props = { +type OAuthProviderSidebarProps = { title: string; icon: IconComponent; - signedIn: boolean; - api: OAuthApi | OpenIdConnectApi; - signInHandler: Function; + apiRef: ApiRef; }; -export const ProviderSettingsItem = ({ +export const ProviderSettingsItem: FC = ({ title, icon: Icon, - signedIn, - api, - signInHandler, -}: Props) => ( - - - - - - - (signedIn ? api.logout() : signInHandler())} - > - { + const api = useApi(apiRef); + const [signedIn, setSignedIn] = useState(false); + + useEffect(() => { + let didCancel = false; + + const subscription = api + .sessionState$() + .subscribe((sessionState: SessionState) => { + if (!didCancel) { + setSignedIn(sessionState === SessionState.SignedIn); + } + }); + + return () => { + didCancel = true; + subscription.unsubscribe(); + }; + }, [api]); + + return ( + + + + + + + (signedIn ? api.signOut() : api.signIn())} > - - - - - -); + + + + + + + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx b/packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx index 9ac287c57e..151ddb6e75 100644 --- a/packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx +++ b/packages/core/src/layout/Sidebar/Settings/UserSettingsMenu.tsx @@ -43,7 +43,7 @@ export const UserSettingsMenu = () => { - identityApi.logout()}> + identityApi.signOut()}> diff --git a/packages/core/src/layout/Sidebar/Settings/index.ts b/packages/core/src/layout/Sidebar/Settings/index.ts index 7abf061620..15042dc85d 100644 --- a/packages/core/src/layout/Sidebar/Settings/index.ts +++ b/packages/core/src/layout/Sidebar/Settings/index.ts @@ -15,6 +15,4 @@ */ export { ProviderSettingsItem } from './ProviderSettingsItem'; -export { OAuthProviderSettings } from './OAuthProviderSettings'; -export { OIDCProviderSettings } from './OIDCProviderSettings'; export { SidebarUserSettings } from './UserSettings'; diff --git a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx index d7451965fe..61975a728a 100644 --- a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx +++ b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx @@ -23,7 +23,7 @@ import { SidebarSearchField, SidebarSpace, SidebarUserSettings, - OAuthProviderSettings, + ProviderSettingsItem, } from '.'; import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined'; import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; @@ -60,7 +60,7 @@ export const SampleSidebar = () => ( { profile: profile!, getIdToken: () => auth0AuthApi.getBackstageIdentity().then(i => i!.idToken), - logout: async () => { - await auth0AuthApi.logout(); + signOut: async () => { + await auth0AuthApi.signOut(); }, }); } catch (error) { @@ -79,8 +79,8 @@ const loader: ProviderLoader = async apis => { userId: identity.id, profile: profile!, getIdToken: () => auth0AuthApi.getBackstageIdentity().then(i => i!.idToken), - logout: async () => { - await auth0AuthApi.logout(); + signOut: async () => { + await auth0AuthApi.signOut(); }, }; }; diff --git a/packages/core/src/layout/SignInPage/commonProvider.tsx b/packages/core/src/layout/SignInPage/commonProvider.tsx index de9452a850..b7480afa49 100644 --- a/packages/core/src/layout/SignInPage/commonProvider.tsx +++ b/packages/core/src/layout/SignInPage/commonProvider.tsx @@ -44,8 +44,8 @@ const Component: ProviderComponent = ({ config, onResult }) => { getIdToken: () => { return authApi.getBackstageIdentity().then(i => i!.idToken); }, - logout: async () => { - await authApi.logout(); + signOut: async () => { + await authApi.signOut(); }, }); } catch (error) { @@ -87,8 +87,8 @@ const loader: ProviderLoader = async (apis, apiRef) => { userId: identity.id, profile: profile!, getIdToken: () => authApi.getBackstageIdentity().then(i => i!.idToken), - logout: async () => { - await authApi.logout(); + signOut: async () => { + await authApi.signOut(); }, }; }; diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index ff60eba228..e2c17ab80d 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -83,14 +83,14 @@ export const useSignInProviders = ( const apiHolder = useApiHolder(); const [loading, setLoading] = useState(true); - // This decorates the result with logout logic from this hook + // This decorates the result with sign out logic from this hook const handleWrappedResult = useCallback( (result: SignInResult) => { onResult({ ...result, - logout: async () => { + signOut: async () => { localStorage.removeItem(PROVIDER_STORAGE_KEY); - await result.logout?.(); + await result.signOut?.(); }, }); }, diff --git a/packages/core/src/layout/SignInPage/types.ts b/packages/core/src/layout/SignInPage/types.ts index 9c501e8095..48945e6988 100644 --- a/packages/core/src/layout/SignInPage/types.ts +++ b/packages/core/src/layout/SignInPage/types.ts @@ -20,19 +20,16 @@ import { SignInResult, ApiHolder, ApiRef, - OAuthApi, ProfileInfoApi, BackstageIdentityApi, - SessionStateApi, + SessionApi, } from '@backstage/core-api'; export type SignInConfig = { id: string; title: string; message: string; - apiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi - >; + apiRef: ApiRef; }; export type IdentityProviders = ('guest' | 'custom' | SignInConfig)[]; @@ -43,9 +40,7 @@ export type ProviderComponent = ComponentType< export type ProviderLoader = ( apis: ApiHolder, - apiRef: ApiRef< - OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi - >, + apiRef: ApiRef, ) => Promise; export type SignInProvider = { diff --git a/packages/create-app/package.json b/packages/create-app/package.json index b303c2423b..4ff0baf4fb 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public" @@ -27,7 +27,7 @@ "start": "nodemon --" }, "dependencies": { - "@backstage/cli-common": "^0.1.1-alpha.22", + "@backstage/cli-common": "^0.1.1-alpha.24", "chalk": "^4.0.0", "commander": "^6.1.0", "fs-extra": "^9.0.0", diff --git a/packages/create-app/templates/default-app/.gitignore b/packages/create-app/templates/default-app/.gitignore.hbs similarity index 96% rename from packages/create-app/templates/default-app/.gitignore rename to packages/create-app/templates/default-app/.gitignore.hbs index 4f9065c60b..5f5cc739f4 100644 --- a/packages/create-app/templates/default-app/.gitignore +++ b/packages/create-app/templates/default-app/.gitignore.hbs @@ -1,4 +1,3 @@ - # Logs logs *.log @@ -31,4 +30,4 @@ dist-types site # Local configuration files -*.local.yaml +*.local.yaml \ No newline at end of file diff --git a/packages/create-app/templates/default-app/app-config.development.yaml b/packages/create-app/templates/default-app/app-config.development.yaml index da274ba1a8..817847c6d6 100644 --- a/packages/create-app/templates/default-app/app-config.development.yaml +++ b/packages/create-app/templates/default-app/app-config.development.yaml @@ -9,3 +9,5 @@ backend: origin: http://localhost:3000 methods: [GET, POST, PUT, DELETE] credentials: true + csp: + connect-src: ["'self'", 'http:', 'https:'] diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index a7d7aa8010..fb6185d51b 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -9,6 +9,8 @@ backend: baseUrl: http://localhost:7000 listen: port: 7000 + csp: + connect-src: ["'self'", 'https:'] {{#if dbTypeSqlite}} database: client: sqlite3 @@ -20,22 +22,17 @@ backend: client: pg connection: host: - $secret: - env: POSTGRES_HOST + $env: POSTGRES_HOST port: - $secret: - env: POSTGRES_PORT + $env: POSTGRES_PORT user: - $secret: - env: POSTGRES_USER + $env: POSTGRES_USER password: - $secret: - env: POSTGRES_PASSWORD + $env: POSTGRES_PASSWORD # https://node-postgres.com/features/ssl #ssl: require # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require) #ca: # if you have a CA file and want to verify it you can uncomment this section - # $secret: - # file: /ca/server.crt + # $file: /ca/server.crt {{/if}} proxy: @@ -44,8 +41,8 @@ proxy: changeOrigin: true techdocs: - storageUrl: http://localhost:7000/techdocs/static/docs - requestUrl: http://localhost:7000/techdocs/docs + storageUrl: http://localhost:7000/api/techdocs/static/docs + requestUrl: http://localhost:7000/api/techdocs/docs generators: techdocs: 'docker' @@ -58,53 +55,50 @@ auth: scaffolder: github: token: - $secret: - env: GITHUB_ACCESS_TOKEN + $env: GITHUB_ACCESS_TOKEN visibility: public # or 'internal' or 'private' catalog: rules: - - allow: [Component, API, Group, Template, Location] + - allow: [Component, API, Group, User, Template, Location] processors: github: providers: - target: https://github.com token: - $secret: - env: GITHUB_PRIVATE_TOKEN + $env: GITHUB_PRIVATE_TOKEN # Example for how to add your GitHub Enterprise instance: # - target: https://ghe.example.net # apiBaseUrl: https://ghe.example.net/api/v3 # token: - # $secret: - # env: GHE_PRIVATE_TOKEN + # $env: GHE_PRIVATE_TOKEN locations: # Backstage example components - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-components.yaml # Backstage example APIs - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml # Backstage example templates - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml rules: - allow: [Template] - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml rules: - allow: [Template] - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml rules: - allow: [Template] - - type: github + - type: url target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml rules: - allow: [Template] - - type: github + - type: url target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml rules: - allow: [Template] diff --git a/packages/create-app/templates/default-app/catalog-info.yaml.hbs b/packages/create-app/templates/default-app/catalog-info.yaml.hbs new file mode 100644 index 0000000000..a370902020 --- /dev/null +++ b/packages/create-app/templates/default-app/catalog-info.yaml.hbs @@ -0,0 +1,13 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: {{name}} + description: An example of a Backstage application. + # Example for optional annotations + # annotations: + # github.com/project-slug: spotify/backstage + # backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git +spec: + type: website + owner: john@example.com + lifecycle: experimental \ No newline at end of file diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index b95b5bbc5b..27606640f3 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "engines": { - "node": "12" + "node": "12 || 14" }, "scripts": { "start": "yarn workspace app start", @@ -16,7 +16,7 @@ "test:all": "lerna run test -- --coverage", "lint": "lerna run lint --since origin/master --", "lint:all": "lerna run lint --", - "create-plugin": "backstage-cli create-plugin", + "create-plugin": "backstage-cli create-plugin --scope internal --no-private", "remove-plugin": "backstage-cli remove-plugin" }, "workspaces": { diff --git a/packages/create-app/templates/default-app/packages/app/public/android-chrome-192x192.png b/packages/create-app/templates/default-app/packages/app/public/android-chrome-192x192.png index 4660f988c1..eec0ae25b9 100644 Binary files a/packages/create-app/templates/default-app/packages/app/public/android-chrome-192x192.png and b/packages/create-app/templates/default-app/packages/app/public/android-chrome-192x192.png differ diff --git a/packages/create-app/templates/default-app/packages/app/public/apple-touch-icon.png b/packages/create-app/templates/default-app/packages/app/public/apple-touch-icon.png index 57c05cfc9a..3158830ac7 100644 Binary files a/packages/create-app/templates/default-app/packages/app/public/apple-touch-icon.png and b/packages/create-app/templates/default-app/packages/app/public/apple-touch-icon.png differ diff --git a/packages/create-app/templates/default-app/packages/app/public/safari-pinned-tab.svg b/packages/create-app/templates/default-app/packages/app/public/safari-pinned-tab.svg index 3b0f666390..0f500b3002 100644 --- a/packages/create-app/templates/default-app/packages/app/public/safari-pinned-tab.svg +++ b/packages/create-app/templates/default-app/packages/app/public/safari-pinned-tab.svg @@ -1,28 +1 @@ - - - - -Created by potrace 1.11, written by Peter Selinger 2001-2013 - - - - - - +Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file diff --git a/packages/create-app/templates/default-app/packages/app/src/App.test.tsx b/packages/create-app/templates/default-app/packages/app/src/App.test.tsx index e88c3b47dd..21a2eaa703 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.test.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.test.tsx @@ -12,7 +12,7 @@ describe('App', () => { app: { title: 'Test' }, backend: { baseUrl: 'http://localhost:7000' }, techdocs: { - storageUrl: 'http://localhost:7000/techdocs/static/docs', + storageUrl: 'http://localhost:7000/api/techdocs/static/docs', }, }, context: 'test', diff --git a/packages/create-app/templates/default-app/packages/app/src/apis.ts b/packages/create-app/templates/default-app/packages/app/src/apis.ts index e0503e504f..24b22a83bc 100644 --- a/packages/create-app/templates/default-app/packages/app/src/apis.ts +++ b/packages/create-app/templates/default-app/packages/app/src/apis.ts @@ -1,17 +1 @@ -import { - discoveryApiRef, - UrlPatternDiscovery, - createApiFactory, - configApiRef, -} from '@backstage/core'; - -export const apis = [ - createApiFactory({ - api: discoveryApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => - UrlPatternDiscovery.compile( - `${configApi.getString('backend.baseUrl')}/{{ pluginId }}`, - ), - }), -]; +export const apis = []; diff --git a/packages/create-app/templates/default-app/packages/backend/Dockerfile b/packages/create-app/templates/default-app/packages/backend/Dockerfile index a7bd814b19..93929ceb86 100644 --- a/packages/create-app/templates/default-app/packages/backend/Dockerfile +++ b/packages/create-app/templates/default-app/packages/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:12 +FROM node:12-buster WORKDIR /usr/src/app diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index adde81e830..7f811e3739 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -5,7 +5,7 @@ "types": "src/index.ts", "private": true, "engines": { - "node": "12" + "node": "12 || 14" }, "scripts": { "build": "backstage-cli backend:build", @@ -22,7 +22,6 @@ "@backstage/config": "^{{version}}", "@backstage/plugin-auth-backend": "^{{version}}", "@backstage/plugin-catalog-backend": "^{{version}}", - "@backstage/plugin-identity-backend": "^{{version}}", "@backstage/plugin-proxy-backend": "^{{version}}", "@backstage/plugin-rollbar-backend": "^{{version}}", "@backstage/plugin-scaffolder-backend": "^{{version}}", @@ -31,6 +30,7 @@ "@gitbeaker/node": "^23.5.0", "dockerode": "^3.2.0", "express": "^4.17.1", + "express-promise-router": "^3.0.3", "knex": "^0.21.1", {{#if dbTypePG}} "pg": "^8.3.0", diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index 6a014727e9..684db8aab8 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -6,17 +6,21 @@ * Happy hacking! */ +import Router from 'express-promise-router'; import { + ensureDatabaseExists, createDatabaseClient, createServiceBuilder, loadBackendConfig, getRootLogger, useHotMemoize, + notFoundHandler, + SingleHostDiscovery, + UrlReaders, } from '@backstage/backend-common'; import { ConfigReader, AppConfig } from '@backstage/config'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; -import identity from './plugins/identity'; import scaffolder from './plugins/scaffolder'; import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; @@ -24,9 +28,14 @@ import { PluginEnvironment } from './types'; function makeCreateEnv(loadedConfigs: AppConfig[]) { const config = ConfigReader.fromConfigs(loadedConfigs); + const root = getRootLogger(); + const reader = UrlReaders.default({ logger: root, config }); + const discovery = SingleHostDiscovery.fromConfig(config); + + root.info(`Created UrlReader ${reader}`); return (plugin: string): PluginEnvironment => { - const logger = getRootLogger().child({ type: 'plugin', plugin }); + const logger = root.child({ type: 'plugin', plugin }); const database = createDatabaseClient( config.getConfig('backend.database'), { @@ -35,7 +44,7 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { }, }, ); - return { logger, database, config }; + return { logger, database, config, reader, discovery }; }; } @@ -43,22 +52,29 @@ 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 catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); - const identityEnv = useHotMemoize(module, () => createEnv('identity')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); + const apiRouter = Router(); + apiRouter.use('/catalog', await catalog(catalogEnv)) + apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)) + apiRouter.use('/auth', await auth(authEnv)) + apiRouter.use('/techdocs', await techdocs(techdocsEnv)) + apiRouter.use('/proxy', await proxy(proxyEnv)) + apiRouter.use(notFoundHandler()); + const service = createServiceBuilder(module) .loadConfig(configReader) - .addRouter('/catalog', await catalog(catalogEnv)) - .addRouter('/scaffolder', await scaffolder(scaffolderEnv)) - .addRouter('/auth', await auth(authEnv)) - .addRouter('/identity', await identity(identityEnv)) - .addRouter('/techdocs', await techdocs(techdocsEnv)) - .addRouter('/proxy', await proxy(proxyEnv, '/proxy')); + .addRouter('/api', apiRouter) await service.start().catch(err => { console.log(err); diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts index 61af87cce0..fe19855d5d 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/auth.ts @@ -5,6 +5,7 @@ export default async function createPlugin({ logger, database, config, + discovery, }: PluginEnvironment) { - return await createRouter({ logger, config, database }); + return await createRouter({ logger, config, database, discovery }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts index aa0538a6d8..345ac4c23d 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts @@ -13,9 +13,10 @@ import { useHotCleanup } from '@backstage/backend-common'; export default async function createPlugin({ logger, config, + reader, database, }: PluginEnvironment) { - const locationReader = new LocationReaders({ logger, config }); + const locationReader = new LocationReaders({ logger, reader, config }); const db = await DatabaseManager.createDatabase(database, { logger }); const entitiesCatalog = new DatabaseEntitiesCatalog(db); diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/identity.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/identity.ts deleted file mode 100644 index f82090eec9..0000000000 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/identity.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createRouter } from '@backstage/plugin-identity-backend'; -import { PluginEnvironment } from '../types'; - -export default async function createPlugin({ logger }: PluginEnvironment) { - return await createRouter({ logger }); -} diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts index b20265c2cd..388d3fc446 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/proxy.ts @@ -2,9 +2,10 @@ import { createRouter } from '@backstage/plugin-proxy-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin( - { logger, config }: PluginEnvironment, - pathPrefix: string, -) { - return await createRouter({ logger, config, pathPrefix }); +export default async function createPlugin({ + logger, + config, + discovery, +}: PluginEnvironment) { + return await createRouter({ logger, config, discovery }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index df5e0a79c1..ed16d766f1 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -10,7 +10,7 @@ import { GitlabPublisher, CreateReactAppTemplater, Templaters, - RepoVisilityOptions, + RepoVisibilityOptions, } from '@backstage/plugin-scaffolder-backend'; import { Octokit } from '@octokit/rest'; import { Gitlab } from '@gitbeaker/node'; @@ -18,8 +18,8 @@ import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; export default async function createPlugin({ - logger, - config, + logger, + config, }: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); @@ -39,31 +39,60 @@ export default async function createPlugin({ const publishers = new Publishers(); - const githubToken = config.getString('scaffolder.github.token'); - const repoVisibility = config.getString( - 'scaffolder.github.visibility', - ) as RepoVisilityOptions; + const githubConfig = config.getOptionalConfig('scaffolder.github'); - const githubClient = new Octokit({ auth: githubToken }); - const githubPublisher = new GithubPublisher({ - client: githubClient, - token: githubToken, - repoVisibility, - }); - publishers.register('file', githubPublisher); - publishers.register('github', githubPublisher); + 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) { - 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); + 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 dockerClient = new Docker(); diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index 9c7de3512b..b522992a75 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -13,6 +13,7 @@ import Docker from 'dockerode'; export default async function createPlugin({ logger, config, + discovery, }: PluginEnvironment) { const generators = new Generators(); const techdocsGenerator = new TechdocsGenerator(logger, config); @@ -37,5 +38,6 @@ export default async function createPlugin({ dockerClient, logger, config, + discovery, }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index 570a5fcdb1..aa003f63a5 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -1,9 +1,12 @@ import Knex from 'knex'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; +import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common'; export type PluginEnvironment = { logger: Logger; database: Knex; config: Config; + reader: UrlReader + discovery: PluginEndpointDiscovery; }; diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 0b6e04f322..05bab2f415 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/core": "^0.1.1-alpha.22", - "@backstage/test-utils": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/core": "^0.1.1-alpha.24", + "@backstage/test-utils": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/docgen/package.json b/packages/docgen/package.json index 3836af6542..99591f5869 100644 --- a/packages/docgen/package.json +++ b/packages/docgen/package.json @@ -1,7 +1,7 @@ { "name": "docgen", "description": "Tool for generating API Documentation for itself", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": true, "homepage": "https://backstage.io", "repository": { diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json index e518d4f710..8dcfd98ab5 100644 --- a/packages/e2e-test/package.json +++ b/packages/e2e-test/package.json @@ -1,7 +1,7 @@ { "name": "e2e-test", "description": "E2E test for verifying Backstage packages", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": true, "homepage": "https://backstage.io", "repository": { @@ -21,7 +21,7 @@ "test:e2e": "yarn start" }, "devDependencies": { - "@backstage/cli-common": "^0.1.1-alpha.22", + "@backstage/cli-common": "^0.1.1-alpha.24", "@types/fs-extra": "^9.0.1", "@types/node": "^13.7.2", "fs-extra": "^9.0.0", diff --git a/packages/e2e-test/src/e2e-test.ts b/packages/e2e-test/src/e2e-test.ts index 146a3ec644..c7f6137ff1 100644 --- a/packages/e2e-test/src/e2e-test.ts +++ b/packages/e2e-test/src/e2e-test.ts @@ -55,6 +55,9 @@ async function main() { print('Creating a Backstage Plugin'); const pluginName = await createPlugin('test-plugin', appDir); + print('Creating a Backstage Backend Plugin'); + await createPlugin('test-backend-plugin', appDir, ['--backend']); + print('Starting the app'); await testAppServe(pluginName, appDir); @@ -81,7 +84,11 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) { const path = paths.resolveOwnRoot(pkgJsonPath); const pkgTemplate = await fs.readFile(path, 'utf8'); const { dependencies = {}, devDependencies = {} } = JSON.parse( - handlebars.compile(pkgTemplate)({ version: '0.0.0' }), + handlebars.compile(pkgTemplate)({ + version: '0.0.0', + privatePackage: true, + scopeName: '@backstage', + }), ); Array() @@ -234,8 +241,12 @@ async function overrideModuleResolutions(appDir: string, workspaceDir: string) { /** * Uses create-plugin command to create a new plugin in the app */ -async function createPlugin(pluginName: string, appDir: string) { - const child = spawnPiped(['yarn', 'create-plugin'], { +async function createPlugin( + pluginName: string, + appDir: string, + options: string[] = [], +) { + const child = spawnPiped(['yarn', 'create-plugin', ...options], { cwd: appDir, }); @@ -380,7 +391,7 @@ async function testBackendStart(appDir: string, isPostgres: boolean) { print('Try to fetch entities from the backend'); // Try fetch entities, should be ok - await fetch('http://localhost:7000/catalog/entities').then(res => + await fetch('http://localhost:7000/api/catalog/entities').then(res => res.json(), ); print('Entities fetched successfully'); diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index a0a220d3de..878256ff5c 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -36,7 +36,7 @@ builder.add(identityApiRef, { getUserId: () => 'guest', getProfile: () => ({ email: 'guest@example.com' }), getIdToken: () => undefined, - logout: async () => {}, + signOut: async () => {}, }); const oauthRequestApi = builder.add( diff --git a/packages/storybook/package.json b/packages/storybook/package.json index a1decf279a..4cfc21be59 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -1,6 +1,6 @@ { "name": "storybook", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "description": "Storybook build for core package", "private": true, "scripts": { @@ -14,7 +14,7 @@ ] }, "dependencies": { - "@backstage/theme": "^0.1.1-alpha.22" + "@backstage/theme": "^0.1.1-alpha.24" }, "devDependencies": { "@storybook/addon-actions": "^6.0.21", diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index 340d9e2aff..77940d8299 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -1,7 +1,7 @@ { "name": "@techdocs/cli", "description": "CLI for running TechDocs locally.", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public" @@ -44,7 +44,7 @@ "ext": "ts" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", "commander": "^6.1.0", "fs-extra": "^9.0.1", "http-proxy": "^1.18.1", diff --git a/packages/techdocs-container/Dockerfile b/packages/techdocs-container/Dockerfile index e910935baf..b34fcbd861 100644 --- a/packages/techdocs-container/Dockerfile +++ b/packages/techdocs-container/Dockerfile @@ -18,7 +18,7 @@ FROM python:3.8-alpine RUN apk update && apk --no-cache add gcc musl-dev openjdk11-jdk curl graphviz ttf-dejavu fontconfig RUN curl -L http://sourceforge.net/projects/plantuml/files/plantuml.1.2020.16.jar/download > /opt/plantuml.jar -RUN pip install --upgrade pip && pip install mkdocs-techdocs-core==0.0.7 +RUN pip install --upgrade pip && pip install mkdocs-techdocs-core==0.0.8 # Create script to call plantuml.jar from a location in path diff --git a/packages/techdocs-container/README.md b/packages/techdocs-container/README.md index aa1bf15974..cc92071f5d 100644 --- a/packages/techdocs-container/README.md +++ b/packages/techdocs-container/README.md @@ -19,3 +19,13 @@ docker run -w /content -v $(pwd)/mock-docs:/content -p 8000:8000 -it mkdocs:loca ``` Then open up `http://localhost:8000` on your local machine. + +## Publishing + +This container is published on DockerHub - https://hub.docker.com/r/spotify/techdocs + +The publishing is configured by [Automated Builds](https://hub.docker.com/repository/docker/spotify/techdocs/builds/edit) feature on Docker Hub which is triggered from GitHub (on new commits and releases). @spotify/techdocs-core team has access to the settings. + +The `latest` tag on Docker Hub points to the recent commits in the `master` branch. The [version tags](https://hub.docker.com/r/spotify/techdocs/tags) (e.g. v0.1.1-alpha.24) point to the GitHub tags created from [releases](https://github.com/spotify/backstage/releases) of this GitHub repository. + +Note: We recommend using a specific version of the container instead of `latest` release for stability and avoiding unexpected changes. diff --git a/packages/techdocs-container/mock-docs/docs/index.md b/packages/techdocs-container/mock-docs/docs/index.md index 2586b86cfb..94a53acfa3 100644 --- a/packages/techdocs-container/mock-docs/docs/index.md +++ b/packages/techdocs-container/mock-docs/docs/index.md @@ -1,12 +1,11 @@ ## hello mock docs !!! test -Testing somethin +Testing something +Abbreviations: Some text about MOCDOC -\*[MOCDOC]: Mock Documentation - This is a paragraph. {: #test_id .test_class } @@ -61,3 +60,40 @@ digraph G { ``` :bulb: + +=== "JavaScript" + + ```javascript + import { test } from 'something'; + + const addThingToThing = (a, b) a + b; + ``` + +=== "Java" + + ```java + public void function() { + test(); + } + ``` + +```java tab="java" + public void function() { + test(); + } +``` + +```java tab="java 2" + public void function() { + test(); + } +``` + +```javascript +import { test } from 'something'; + +const addThingToThing = (a, b) a + b; +``` + + +*[MOCDOC]: Mock Documentation diff --git a/packages/techdocs-container/techdocs-core/README.md b/packages/techdocs-container/techdocs-core/README.md index 36f1437a54..7f34722e81 100644 --- a/packages/techdocs-container/techdocs-core/README.md +++ b/packages/techdocs-container/techdocs-core/README.md @@ -48,8 +48,76 @@ python -m black src/ **Note:** This will write to all Python files in `src/` with the formatted code. If you would like to only check to see if it passes, simply append the `--check` flag. +## MkDocs plugins and extensions + +The TechDocs Core MkDocs plugin comes with a set of extensions and plugins that mkdocs supports. Below you can find a list of all extensions and plugins that are included in the +TechDocs Core plugin: + +Plugins: + +- [search](https://www.mkdocs.org/user-guide/configuration/#search) +- [mkdocs-monorepo-plugin](https://github.com/spotify/mkdocs-monorepo-plugin) + +Extensions: + +- [admonition](https://squidfunk.github.io/mkdocs-material/reference/admonitions/#admonitions) +- [toc](https://python-markdown.github.io/extensions/toc/) +- [pymdown](https://facelessuser.github.io/pymdown-extensions/) + - caret + - critic + - details + - emoji + - superfences + - inlinehilite + - magiclink + - mark + - smartsymobls + - highlight + - extra + - tabbed + - tasklist + - tilde +- [markdown_inline_graphviz](https://pypi.org/project/markdown-inline-graphviz/) +- [plantuml_markdown](https://pypi.org/project/plantuml-markdown/) + ## Changelog +### 0.0.9 + +- Change development status to 3 - Alpha + +### 0.0.8 + +- Superfences and Codehilite doesn't work very well together (squidfunk/mkdocs-material#1604) so therefore the codehilite extension is replaced by pymdownx.highlight + +* Uses pymdownx extensions v.7.1 instead of 8.0.0 to allow legacy_tab_classes config. This makes the techdocs core plugin compatible with the usage of tabs for grouping markdown with the following syntax: + +```` + ```java tab="java 2" + public void function() { + .... + } + ``` +```` + +as well as the new + +```` + === "Java" + + ```java + public void function() { + .... + } + ``` +```` + +The pymdownx extension will be bumped too 8.0.0 in the near future. + +- pymdownx.tabbed is added to support tabs to group markdown content, such as codeblocks. + +- "PyMdown Extensions includes three extensions that are meant to replace their counterpart in the default Python Markdown extensions." Therefore some extensions has been taken away in this version that comes by default from pymdownx.extra which is added now (https://facelessuser.github.io/pymdown-extensions/usage_notes/#incompatible-extensions) + ### 0.0.7 - Fix an issue with configuration of emoji support diff --git a/packages/techdocs-container/techdocs-core/requirements.txt b/packages/techdocs-container/techdocs-core/requirements.txt index 36daaab4d7..75546fa992 100644 --- a/packages/techdocs-container/techdocs-core/requirements.txt +++ b/packages/techdocs-container/techdocs-core/requirements.txt @@ -7,7 +7,7 @@ mkdocs-monorepo-plugin==0.4.5 plantuml-markdown==3.1.2 markdown_inline_graphviz_extension==1.1 pygments==2.6.1 -pymdown-extensions==8.0.0 +pymdown-extensions==7.1 # The linter using for Python # Note: This requires Python 3.6+ to run, but can format Python 2 code too. diff --git a/packages/techdocs-container/techdocs-core/setup.py b/packages/techdocs-container/techdocs-core/setup.py index 3e3fa47caa..4385bc2e38 100644 --- a/packages/techdocs-container/techdocs-core/setup.py +++ b/packages/techdocs-container/techdocs-core/setup.py @@ -14,41 +14,43 @@ See the License for the specific language governing permissions and limitations under the License. """ from setuptools import setup, find_packages +from os import path +# read the contents of the README file in the current directory +this_dir = path.abspath(path.dirname(__file__)) +with open(path.join(this_dir, "README.md"), encoding="utf-8") as file: + long_description = file.read() setup( - name='mkdocs-techdocs-core', - version='0.0.7', - description='A Mkdocs package that contains TechDocs defaults', - long_description='', - keywords='mkdocs', - url='https://github.com/spotify/backstage', - author='TechDocs Core', - author_email='pulp-fiction@spotify.com', - license='Apache-2.0', - python_requires='>=3.7', + name="mkdocs-techdocs-core", + version="0.0.9", + description="A Mkdocs package that contains TechDocs defaults", + long_description=long_description, + long_description_content_type="text/markdown", + keywords="mkdocs", + url="https://github.com/spotify/backstage", + author="TechDocs Core", + author_email="pulp-fiction@spotify.com", + license="Apache-2.0", + python_requires=">=3.7", install_requires=[ - 'mkdocs>=1.1.2', - 'mkdocs-material==5.3.2', - 'mkdocs-monorepo-plugin==0.4.5', - 'plantuml-markdown==3.1.2', - 'markdown_inline_graphviz_extension==1.1', - 'pygments==2.6.1', - 'pymdown-extensions==8.0.0' + "mkdocs>=1.1.2", + "mkdocs-material==5.3.2", + "mkdocs-monorepo-plugin==0.4.5", + "plantuml-markdown==3.1.2", + "markdown_inline_graphviz_extension==1.1", + "pygments==2.6.1", + "pymdown-extensions==7.1", ], classifiers=[ - 'Development Status :: 1 - Planning', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.7' + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", ], packages=find_packages(), - entry_points={ - 'mkdocs.plugins': [ - 'techdocs-core = src.core:TechDocsCore' - ] - } + entry_points={"mkdocs.plugins": ["techdocs-core = src.core:TechDocsCore"]}, ) diff --git a/packages/techdocs-container/techdocs-core/src/core.py b/packages/techdocs-container/techdocs-core/src/core.py index 69e39d401b..ed1eb7c6f6 100644 --- a/packages/techdocs-container/techdocs-core/src/core.py +++ b/packages/techdocs-container/techdocs-core/src/core.py @@ -14,7 +14,7 @@ * limitations under the License. """ -from mkdocs.plugins import BasePlugin, PluginCollection +from mkdocs.plugins import BasePlugin from mkdocs.theme import Theme from mkdocs.contrib.search import SearchPlugin from mkdocs_monorepo_plugin.plugin import MonorepoPlugin @@ -54,25 +54,11 @@ class TechDocsCore(BasePlugin): # Markdown Extensions config["markdown_extensions"].append("admonition") - config["markdown_extensions"].append("abbr") - config["markdown_extensions"].append("attr_list") - config["markdown_extensions"].append("def_list") - config["markdown_extensions"].append("codehilite") - config["mdx_configs"]["codehilite"] = { - "linenums": True, - "guess_lang": False, - "pygments_style": "friendly", - } config["markdown_extensions"].append("toc") config["mdx_configs"]["toc"] = { "permalink": True, } - config["markdown_extensions"].append("footnotes") - config["markdown_extensions"].append("markdown.extensions.tables") - config["markdown_extensions"].append("pymdownx.betterem") - config["mdx_configs"]["pymdownx.betterem"] = { - "smart_enable": "all", - } + config["markdown_extensions"].append("pymdownx.caret") config["markdown_extensions"].append("pymdownx.critic") config["markdown_extensions"].append("pymdownx.details") @@ -83,6 +69,18 @@ class TechDocsCore(BasePlugin): config["markdown_extensions"].append("pymdownx.mark") config["markdown_extensions"].append("pymdownx.smartsymbols") config["markdown_extensions"].append("pymdownx.superfences") + config["mdx_configs"]["pymdownx.superfences"] = { + "legacy_tab_classes": True, + } + config["markdown_extensions"].append("pymdownx.highlight") + config["mdx_configs"]["pymdownx.highlight"] = { + "linenums": True, + } + config["markdown_extensions"].append("pymdownx.extra") + config["mdx_configs"]["pymdownx.betterem"] = { + "smart_enable": "all", + } + config["markdown_extensions"].append("pymdownx.tabbed") config["markdown_extensions"].append("pymdownx.tasklist") config["mdx_configs"]["pymdownx.tasklist"] = { "custom_checkbox": True, diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json index d79acf80a8..ffdcdc02fb 100644 --- a/packages/test-utils-core/package.json +++ b/packages/test-utils-core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils-core", "description": "Utilities to test Backstage core", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index cb115fe633..efe5cafaed 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/core-api": "^0.1.1-alpha.22", - "@backstage/test-utils-core": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/core-api": "^0.1.1-alpha.24", + "@backstage/test-utils-core": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/theme/package.json b/packages/theme/package.json index a1a3d6d111..e9f3a346ce 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22" + "@backstage/cli": "^0.1.1-alpha.24" }, "files": [ "dist" diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 73d6c13859..0ec3304ac5 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -44,6 +44,8 @@ export const lightTheme = createTheme({ banner: { info: '#2E77D0', error: '#E22134', + text: '#FFFFFF', + link: '#000000', }, border: '#E6E6E6', textContrast: '#000000', @@ -80,12 +82,12 @@ export const darkTheme = createTheme({ default: '#333333', }, status: { - ok: '#1DB954', - warning: '#FF9800', - error: '#E22134', - running: '#2E77D0', - pending: '#FFED51', - aborted: '#757575', + ok: '#71CF88', + warning: '#FFB84D', + error: '#F84C55', + running: '#3488E3', + pending: '#FEF071', + aborted: '#9E9E9E', }, bursts: { fontColor: '#FEFEFE', @@ -100,11 +102,13 @@ export const darkTheme = createTheme({ banner: { info: '#2E77D0', error: '#E22134', + text: '#FFFFFF', + link: '#000000', }, border: '#E6E6E6', textContrast: '#FFFFFF', - textVerySubtle: '#DDD', - textSubtle: '#EEEEEE', + textVerySubtle: '#727272', + textSubtle: '#CCCCCC', highlight: '#FFFBCC', errorBackground: '#FFEBEE', warningBackground: '#F59B23', diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index 66bfb4c6fd..1d244f8cd4 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -64,6 +64,8 @@ type PaletteAdditions = { banner: { info: string; error: string; + text: string; + link: string; }; }; diff --git a/plugins/README.md b/plugins/README.md index 91ad94b883..d56758b8f0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -2,7 +2,7 @@ Backstage is a single-page application composed of a set of plugins. -Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong [design guidelines](https://github.com/spotify/backstage/blob/master/docs/dls/design.md) we ensure the the overall user experience stays consistent between plugins. +Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong [design guidelines](https://github.com/spotify/backstage/blob/master/docs/dls/design.md) we ensure the overall user experience stays consistent between plugins. ![plugin](../docs/assets/my-plugin_screenshot.png) @@ -12,6 +12,6 @@ To create a plugin, follow the steps outlined [here](https://github.com/spotify/ ## Suggesting a plugin -If you start developing a plugin that you aim to release as open source, we suggest that you create a new [new Issue](https://github.com/spotify/backstage/issues/new?template=plugin_template.md). 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/spotify/backstage/issues/new?template=plugin_template.md). 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. diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md index 17f354e997..ecc0e5a560 100644 --- a/plugins/api-docs/README.md +++ b/plugins/api-docs/README.md @@ -18,7 +18,7 @@ Right now, the following API formats are supported: - [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/) - [GraphQL](https://graphql.org/learn/schema/) -Other formats are displayed as plain text, but this can easily be extented. +Other formats are displayed as plain text, but this can easily be extended. To fill the catalog with APIs, [provide entities of kind API](https://backstage.io/docs/features/software-catalog/descriptor-format#kind-api). To link that an component implements an API, see [`implementsApis` property on components](https://backstage.io/docs/features/software-catalog/descriptor-format#specimplementsapis-optional). diff --git a/plugins/api-docs/docs/api_list.png b/plugins/api-docs/docs/api_list.png index abc948b3c6..4ca2f09542 100644 Binary files a/plugins/api-docs/docs/api_list.png and b/plugins/api-docs/docs/api_list.png differ diff --git a/plugins/api-docs/docs/entity_tab_api.png b/plugins/api-docs/docs/entity_tab_api.png index 1ab9de02cd..439fac73fa 100644 Binary files a/plugins/api-docs/docs/entity_tab_api.png and b/plugins/api-docs/docs/entity_tab_api.png differ diff --git a/plugins/api-docs/docs/openapi_definition.png b/plugins/api-docs/docs/openapi_definition.png index af04946b19..49d12a08e8 100644 Binary files a/plugins/api-docs/docs/openapi_definition.png and b/plugins/api-docs/docs/openapi_definition.png differ diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 378ceb2d08..a703b6afe5 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "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.22", - "@backstage/core": "^0.1.1-alpha.22", - "@backstage/plugin-catalog": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", - "@kyma-project/asyncapi-react": "^0.11.0", + "@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", + "@kyma-project/asyncapi-react": "^0.13.1", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -39,9 +39,9 @@ "swagger-ui-react": "^3.31.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", - "@backstage/test-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/test-utils": "^0.1.1-alpha.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", @@ -49,7 +49,9 @@ "@types/node": "^12.0.0", "@types/react": "^16.9", "@types/swagger-ui-react": "^3.23.3", - "jest-fetch-mock": "^3.0.3" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" }, "files": [ "dist" diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx index ac5dedfd9c..86f524d099 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx @@ -96,10 +96,10 @@ const useStyles = makeStyles(theme => ({ content: '"🔗"', 'font-family': 'inherit', }, - '& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': { + '& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__channel-operations-list .asyncapi__messages-list-item .asyncapi__message, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': { 'background-color': 'inherit', }, - '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header': { + '& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header': { 'background-color': 'inherit', color: theme.palette.text.primary, }, diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index c4738c7105..98873f88c7 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "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.22", - "@backstage/config-loader": "^0.1.1-alpha.22", + "@backstage/backend-common": "^0.1.1-alpha.24", + "@backstage/config-loader": "^0.1.1-alpha.24", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -30,9 +30,9 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", "@types/supertest": "^2.0.8", - "msw": "^0.19.5", + "msw": "^0.20.5", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index 9af90df3f2..355fcc1536 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -34,8 +34,8 @@ Follow this link, [Create new OAuth App](https://github.com/settings/application 1. Set Application Name to `backstage-dev` or something along those lines. 1. You can set the Homepage URL to whatever you want to. 1. The Authorization Callback URL should match the redirect URI set in Backstage. - 1. Set this to `http://localhost:7000/auth/github` for local development. - 1. Set this to `http://{APP_FQDN}:{APP_BACKEND_PORT}/auth/github` for non-local deployments. + 1. Set this to `http://localhost:7000/api/auth/github` for local development. + 1. Set this to `http://{APP_FQDN}:{APP_BACKEND_PORT}/api/auth/github` for non-local deployments. ```bash export AUTH_GITHUB_CLIENT_ID=x @@ -78,14 +78,14 @@ export AUTH_AUTH0_CLIENT_SECRET=x #### Creating an Azure AD App Registration -An Azure AD App Registration is required to be able to sign in using Azure AD and the Microsoft Graph API. +An Azure AD App Registration is required to be able to sign in using Azure AD and the Microsoft Graph API. Click [here](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) to create a new one. - Click on the `New Registration` button. - Give the app a name. e.g. `backstage-dev` - Select `Accounts in this organizational directory only` under supported account types. - Enter the callback URL for your backstage backend instance: - - For local development, this is likely `http://localhost:7000/auth/microsoft/handler/frame` + - For local development, this is likely `http://localhost:7000/api/auth/microsoft/handler/frame` - For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame` - Click `Register`. diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 91fe08c0fa..fca8fb7178 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,8 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.22", - "@backstage/config": "^0.1.1-alpha.22", + "@backstage/backend-common": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", "@types/express": "^4.17.6", "compression": "^1.7.4", "cookie-parser": "^1.4.5", @@ -36,6 +37,7 @@ "knex": "^0.21.1", "moment": "^2.26.0", "morgan": "^1.10.0", + "node-fetch": "^2.6.1", "passport": "^0.4.1", "passport-github2": "^0.1.12", "passport-gitlab2": "^5.0.0", @@ -49,16 +51,18 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/jwt-decode": "2.2.1", + "@types/node-fetch": "^2.5.7", "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", "@types/passport-microsoft": "^0.0.0", "@types/passport-saml": "^1.1.2", - "jest-fetch-mock": "^3.0.3" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5" }, "files": [ "dist", diff --git a/plugins/auth-backend/scripts/start-saml-idp.sh b/plugins/auth-backend/scripts/start-saml-idp.sh index 33217f7978..b312a4b85e 100755 --- a/plugins/auth-backend/scripts/start-saml-idp.sh +++ b/plugins/auth-backend/scripts/start-saml-idp.sh @@ -18,4 +18,4 @@ fi echo "Downloading and starting SAML-IdP" export NPM_CONFIG_REGISTRY=https://registry.npmjs.org -exec npx saml-idp --acsUrl "http://localhost:7000/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001 +exec npx saml-idp --acsUrl "http://localhost:7000/api/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001 diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts new file mode 100644 index 0000000000..2c1d66e1f8 --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'node-fetch'; +import { UserEntity } from '@backstage/catalog-model'; +import { + ConflictError, + NotFoundError, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; + +type UserQuery = { + annotations: Record; +}; + +/** + * A catalog client tailored for reading out identity data from the catalog. + */ +export class CatalogIdentityClient { + private readonly discovery: PluginEndpointDiscovery; + + constructor(options: { discovery: PluginEndpointDiscovery }) { + this.discovery = options.discovery; + } + + /** + * Looks up a single user using a query. + * + * Throws a NotFoundError or ConflictError if 0 or multiple users are found. + */ + async findUser(query: UserQuery): Promise { + const params = new URLSearchParams(); + params.append('kind', 'User'); + + for (const [key, value] of Object.entries(query.annotations)) { + params.append(`metadata.annotations.${key}`, value); + } + + const baseUrl = await this.discovery.getBaseUrl('catalog'); + const response = await fetch(`${baseUrl}/entities?${params}`); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Request failed with ${response.status} ${response.statusText}, ${text}`, + ); + } + + const users: UserEntity[] = await response.json(); + + if (users.length !== 1) { + if (users.length > 1) { + throw new ConflictError('User lookup resulted in multiple matches'); + } else { + throw new NotFoundError('User not found'); + } + } + + return users[0]; + } +} diff --git a/plugins/auth-backend/src/lib/catalog/index.ts b/plugins/auth-backend/src/lib/catalog/index.ts new file mode 100644 index 0000000000..f15469668f --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/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 { CatalogIdentityClient } from './CatalogIdentityClient'; diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 6c83dffda3..37761c2305 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -14,9 +14,6 @@ * limitations under the License. */ -import Router from 'express-promise-router'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../identity'; import { createGithubProvider } from './github'; import { createGitlabProvider } from './gitlab'; import { createGoogleProvider } from './google'; @@ -25,8 +22,7 @@ import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; import { createAuth0Provider } from './auth0'; import { createMicrosoftProvider } from './microsoft'; -import { AuthProviderConfig, AuthProviderFactory } from './types'; -import { Config } from '@backstage/config'; +import { AuthProviderFactory, AuthProviderFactoryOptions } from './types'; const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -39,31 +35,14 @@ const factories: { [providerId: string]: AuthProviderFactory } = { oauth2: createOAuth2Provider, }; -export const createAuthProviderRouter = ( +export function createAuthProvider( providerId: string, - globalConfig: AuthProviderConfig, - config: Config, - logger: Logger, - tokenIssuer: TokenIssuer, -) => { + options: AuthProviderFactoryOptions, +) { const factory = factories[providerId]; if (!factory) { throw Error(`No auth provider available for '${providerId}'`); } - const router = Router(); - - const handler = factory({ globalConfig, config, logger, tokenIssuer }); - - router.get('/start', handler.start.bind(handler)); - router.get('/handler/frame', handler.frameHandler.bind(handler)); - router.post('/handler/frame', handler.frameHandler.bind(handler)); - if (handler.logout) { - router.post('/logout', handler.logout.bind(handler)); - } - if (handler.refresh) { - router.get('/refresh', handler.refresh.bind(handler)); - } - - return router; -}; + return factory(options); +} diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 3cc585605c..80143ca201 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -15,6 +15,7 @@ */ import express from 'express'; +import { Logger } from 'winston'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { executeFrameHandlerStrategy, @@ -36,15 +37,25 @@ import { OAuthRefreshRequest, } from '../../lib/oauth'; import passport from 'passport'; +import { CatalogIdentityClient } from '../../lib/catalog'; type PrivateInfo = { refreshToken: string; }; +export type GoogleAuthProviderOptions = OAuthProviderOptions & { + logger: Logger; + identityClient: CatalogIdentityClient; +}; + export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; + private readonly logger: Logger; + private readonly identityClient: CatalogIdentityClient; - constructor(options: OAuthProviderOptions) { + constructor(options: GoogleAuthProviderOptions) { + this.logger = options.logger; + this.identityClient = options.identityClient; // TODO: throw error if env variables not set? this._strategy = new GoogleStrategy( { @@ -138,17 +149,37 @@ export class GoogleAuthProvider implements OAuthHandlers { throw new Error('Google profile contained no email'); } - // TODO(Rugvip): Hardcoded to the local part of the email for now - const id = profile.email.split('@')[0]; + try { + const user = await this.identityClient.findUser({ + annotations: { + 'google.com/email': profile.email, + }, + }); - return { ...response, backstageIdentity: { id } }; + return { + ...response, + backstageIdentity: { + id: user.metadata.name, + }, + }; + } catch (error) { + this.logger.warn( + `Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`, + ); + return { + ...response, + backstageIdentity: { id: profile.email.split('@')[0] }, + }; + } } } export const createGoogleProvider: AuthProviderFactory = ({ globalConfig, config, + logger, tokenIssuer, + discovery, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const providerId = 'google'; @@ -160,6 +191,8 @@ export const createGoogleProvider: AuthProviderFactory = ({ clientId, clientSecret, callbackUrl, + logger, + identityClient: new CatalogIdentityClient({ discovery }), }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index d210bfd1bb..99348438be 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { createAuthProviderRouter } from './factories'; +export { createAuthProvider } from './factories'; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 2bd8ed0bf3..a90f3e3053 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -54,7 +54,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { // TODO: This flow doesn't issue an identity token that can be used to validate // the identity of the user in other backends, which we need in some form. done(undefined, { - userId: profile.ID!, + userId: profile.nameID!, profile: { email: profile.email!, displayName: profile.displayName as string, diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 5c05b02739..6776095f99 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -18,6 +18,7 @@ import express from 'express'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; import { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; export type AuthProviderConfig = { /** @@ -115,6 +116,7 @@ export type AuthProviderFactoryOptions = { config: Config; logger: Logger; tokenIssuer: TokenIssuer; + discovery: PluginEndpointDiscovery; }; export type AuthProviderFactory = ( diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 19a74d47c5..bcd1fc1794 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -19,32 +19,35 @@ import Router from 'express-promise-router'; import cookieParser from 'cookie-parser'; import Knex from 'knex'; import { Logger } from 'winston'; -import { createAuthProviderRouter } from '../providers'; +import { createAuthProvider } from '../providers'; import { Config } from '@backstage/config'; import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity'; -import { NotFoundError } from '@backstage/backend-common'; +import { + NotFoundError, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; export interface RouterOptions { logger: Logger; database: Knex; config: Config; + discovery: PluginEndpointDiscovery; } -export async function createRouter( - options: RouterOptions, -): Promise { +export async function createRouter({ + logger, + config, + discovery, + database, +}: RouterOptions): Promise { const router = Router(); - const logger = options.logger.child({ plugin: 'auth' }); - const appUrl = options.config.getString('app.baseUrl'); - const backendUrl = options.config.getString('backend.baseUrl'); - const authUrl = `${backendUrl}/auth`; + const appUrl = config.getString('app.baseUrl'); + const authUrl = await discovery.getExternalBaseUrl('auth'); const keyDurationSeconds = 3600; - const keyStore = await DatabaseKeyStore.create({ - database: options.database, - }); + const keyStore = await DatabaseKeyStore.create({ database }); const tokenIssuer = new TokenFactory({ issuer: authUrl, keyStore, @@ -56,21 +59,33 @@ export async function createRouter( router.use(express.urlencoded({ extended: false })); router.use(express.json()); - const providersConfig = options.config.getConfig('auth.providers'); + const providersConfig = config.getConfig('auth.providers'); const providers = providersConfig.keys(); for (const providerId of providers) { logger.info(`Configuring provider, ${providerId}`); try { - const providerConfig = providersConfig.getConfig(providerId); - const providerRouter = createAuthProviderRouter( - providerId, - { baseUrl: authUrl, appUrl }, - providerConfig, + const provider = createAuthProvider(providerId, { + globalConfig: { baseUrl: authUrl, appUrl }, + config: providersConfig.getConfig(providerId), logger, tokenIssuer, - ); - router.use(`/${providerId}`, providerRouter); + discovery, + }); + + const r = Router(); + + r.get('/start', provider.start.bind(provider)); + r.get('/handler/frame', provider.frameHandler.bind(provider)); + r.post('/handler/frame', provider.frameHandler.bind(provider)); + if (provider.logout) { + r.post('/logout', provider.logout.bind(provider)); + } + if (provider.refresh) { + r.get('/refresh', provider.refresh.bind(provider)); + } + + router.use(`/${providerId}`, r); } catch (e) { if (process.env.NODE_ENV !== 'development') { throw new Error( diff --git a/plugins/auth-backend/src/service/standaloneServer.ts b/plugins/auth-backend/src/service/standaloneServer.ts index dc91c92631..71771340a5 100644 --- a/plugins/auth-backend/src/service/standaloneServer.ts +++ b/plugins/auth-backend/src/service/standaloneServer.ts @@ -23,6 +23,7 @@ import { createServiceBuilder, useHotMemoize, loadBackendConfig, + SingleHostDiscovery, } from '@backstage/backend-common'; export interface ServerOptions { @@ -34,6 +35,7 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'auth-backend' }); const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const discovery = SingleHostDiscovery.fromConfig(config); const database = useHotMemoize(module, () => { const knex = Knex({ @@ -52,6 +54,7 @@ export async function startStandaloneServer( logger, config, database, + discovery, }); const service = createServiceBuilder(module) diff --git a/plugins/catalog-backend/migrations/20200923104503_case_insensitivity.js b/plugins/catalog-backend/migrations/20200923104503_case_insensitivity.js new file mode 100644 index 0000000000..eeec950d7f --- /dev/null +++ b/plugins/catalog-backend/migrations/20200923104503_case_insensitivity.js @@ -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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + await knex('entities') + .where({ namespace: null }) + .update({ namespace: 'default' }); + await knex('entities_search').update({ + key: knex.raw('LOWER(key)'), + value: knex.raw('LOWER(value)'), + }); +}; + +exports.down = async function down() {}; diff --git a/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js b/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js new file mode 100644 index 0000000000..26cc98e74e --- /dev/null +++ b/plugins/catalog-backend/migrations/20201005122705_add_entity_full_name.js @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('entities', table => { + table.text('full_name').nullable(); + }); + + await knex('entities').update({ + full_name: knex.raw( + "LOWER(kind) || ':' || LOWER(COALESCE(namespace, 'default')) || '/' || LOWER(name)", + ), + }); + + try { + await knex.schema.alterTable('entities', table => { + table.text('full_name').notNullable().alter(); + }); + } catch (e) { + // SQLite does not support alter column, ignore + } + + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.unique(['full_name'], 'entities_unique_full_name'); + table.dropUnique([], 'entities_unique_name'); + }); +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('entities', table => { + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta + table.dropUnique([], 'entities_unique_full_name'); + table.unique(['kind', 'namespace', 'name'], 'entities_unique_name'); + }); + + await knex.schema.alterTable('entities_search', table => { + table.dropColumn('full_name'); + }); +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index f443ff362f..f16f629c44 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,19 +20,24 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.22", - "@backstage/catalog-model": "^0.1.1-alpha.22", - "@backstage/config": "^0.1.1-alpha.22", + "@backstage/backend-common": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", + "@octokit/graphql": "^4.5.6", "@types/express": "^4.17.6", + "@types/node-fetch": "^2.5.7", + "codeowners-utils": "^1.0.2", + "core-js": "^3.6.5", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", "git-url-parse": "^11.2.0", "knex": "^0.21.1", + "ldapjs": "^2.2.0", "lodash": "^4.17.15", "morgan": "^1.10.0", "node-fetch": "^2.6.0", - "sqlite3": "^4.2.0", + "sqlite3": "^5.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yaml": "^1.9.2", @@ -40,10 +45,11 @@ "yup": "^0.29.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", + "@types/ldapjs": "^1.0.9", "@types/lodash": "^4.14.151", - "@types/node-fetch": "^2.5.7", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", "@types/yup": "^0.28.2", diff --git a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts index bcd1248a66..1cfea23e43 100644 --- a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { Logger } from 'winston'; import { CoalescedEntitiesCatalog } from './CoalescedEntitiesCatalog'; import { EntitiesCatalog } from './types'; @@ -115,9 +115,13 @@ describe('CoalescedEntitiesCatalog', () => { c1.entityByName.mockResolvedValueOnce(undefined); c2.entityByName.mockResolvedValueOnce(e2); const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe( - e2, - ); + await expect( + catalog.entityByName({ + kind: 'k', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'n2', + }), + ).resolves.toBe(e2); expect(c1.entityByName).toBeCalledTimes(1); expect(c2.entityByName).toBeCalledTimes(1); }); @@ -127,7 +131,11 @@ describe('CoalescedEntitiesCatalog', () => { c2.entityByName.mockResolvedValueOnce(undefined); const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); await expect( - catalog.entityByName('k', undefined, 'n2'), + catalog.entityByName({ + kind: 'k', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'n2', + }), ).resolves.toBeUndefined(); expect(c1.entityByName).toBeCalledTimes(1); expect(c2.entityByName).toBeCalledTimes(1); @@ -137,9 +145,13 @@ describe('CoalescedEntitiesCatalog', () => { c1.entityByName.mockResolvedValueOnce(e1); c2.entityByName.mockRejectedValueOnce(new Error('boo')); const catalog = new CoalescedEntitiesCatalog([c1, c2], logger); - await expect(catalog.entityByName('k', undefined, 'n2')).resolves.toBe( - e1, - ); + await expect( + catalog.entityByName({ + kind: 'k', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'n2', + }), + ).resolves.toBe(e1); expect(c1.entityByName).toBeCalledTimes(1); expect(c2.entityByName).toBeCalledTimes(1); expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/)); diff --git a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts index 5a0922495e..7eaf1868cb 100644 --- a/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/CoalescedEntitiesCatalog.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, EntityName } from '@backstage/catalog-model'; import { Logger } from 'winston'; import { EntityFilters } from '../database'; import { EntitiesCatalog } from './types'; @@ -72,14 +72,10 @@ export class CoalescedEntitiesCatalog implements EntitiesCatalog { return results.find(Boolean); } - async entityByName( - kind: string, - namespace: string | undefined, - name: string, - ): Promise { + async entityByName(name: EntityName): Promise { const ops = this.inner.map(async catalog => { try { - return await catalog.entityByName(kind, namespace, name); + return await catalog.entityByName(name); } catch (e) { this.logger.warn(`Inner entityByName call failed, ${e}`); return undefined; diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 5d35fe814e..b94365a9ba 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -27,7 +27,7 @@ describe('DatabaseEntitiesCatalog', () => { addEntity: jest.fn(), updateEntity: jest.fn(), entities: jest.fn(), - entity: jest.fn(), + entityByName: jest.fn(), entityByUid: jest.fn(), removeEntity: jest.fn(), addLocation: jest.fn(), @@ -61,12 +61,12 @@ describe('DatabaseEntitiesCatalog', () => { const catalog = new DatabaseEntitiesCatalog(db); const result = await catalog.addOrUpdateEntity(entity); - expect(db.entities).toHaveBeenCalledTimes(1); - expect(db.entities).toHaveBeenCalledWith(expect.anything(), [ - { key: 'kind', values: ['b'] }, - { key: 'name', values: ['c'] }, - { key: 'namespace', values: ['d'] }, - ]); + expect(db.entityByName).toHaveBeenCalledTimes(1); + expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), { + kind: 'b', + namespace: 'd', + name: 'c', + }); expect(db.addEntity).toHaveBeenCalledTimes(1); expect(result).toBe(entity); }); @@ -146,18 +146,18 @@ describe('DatabaseEntitiesCatalog', () => { }, }; - db.entities.mockResolvedValue([{ entity: existing }]); + db.entityByName.mockResolvedValue({ entity: existing }); db.updateEntity.mockResolvedValue({ entity: existing }); const catalog = new DatabaseEntitiesCatalog(db); const result = await catalog.addOrUpdateEntity(added); - expect(db.entities).toHaveBeenCalledTimes(1); - expect(db.entities).toHaveBeenCalledWith(expect.anything(), [ - { key: 'kind', values: ['b'] }, - { key: 'name', values: ['c'] }, - { key: 'namespace', values: ['d'] }, - ]); + expect(db.entityByName).toHaveBeenCalledTimes(1); + expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), { + kind: 'b', + namespace: 'd', + name: 'c', + }); expect(db.updateEntity).toHaveBeenCalledTimes(1); expect(db.updateEntity).toHaveBeenCalledWith( expect.anything(), diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index d6809e468f..750d728c67 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -16,7 +16,9 @@ import { NotFoundError } from '@backstage/backend-common'; import { + EntityName, generateUpdatedEntity, + getEntityName, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; import type { Entity } from '@backstage/catalog-model'; @@ -34,20 +36,15 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { } async entityByUid(uid: string): Promise { - const matches = await this.database.transaction(tx => - this.database.entities(tx, [{ key: 'uid', values: [uid] }]), + const response = await this.database.transaction(tx => + this.database.entityByUid(tx, uid), ); - - return matches.length ? matches[0].entity : undefined; + return response?.entity; } - async entityByName( - kind: string, - namespace: string | undefined, - name: string, - ): Promise { + async entityByName(name: EntityName): Promise { const response = await this.database.transaction(tx => - this.entityByNameInternal(tx, kind, name, namespace), + this.database.entityByName(tx, name), ); return response?.entity; } @@ -61,12 +58,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // entity) existing entity, to know whether to update or add const existing = entity.metadata.uid ? await this.database.entityByUid(tx, entity.metadata.uid) - : await this.entityByNameInternal( - tx, - entity.kind, - entity.metadata.name, - entity.metadata.namespace, - ); + : await this.database.entityByName(tx, getEntityName(entity)); // If it's an update, run the algorithm for annotation merging, updating // etag/generation, etc. @@ -98,7 +90,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { const colocatedEntities = location ? await this.database.entities(tx, [ { - key: LOCATION_ANNOTATION, + key: `metadata.annotations.${LOCATION_ANNOTATION}`, values: [location], }, ]) @@ -113,25 +105,4 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { return undefined; }); } - - private async entityByNameInternal( - tx: unknown, - kind: string, - name: string, - namespace: string | undefined, - ): Promise { - const matches = await this.database.entities(tx, [ - { key: 'kind', values: [kind] }, - { key: 'name', values: [name] }, - { - key: 'namespace', - values: - !namespace || namespace === 'default' - ? [null, 'default'] - : [namespace], - }, - ]); - - return matches.length ? matches[0] : undefined; - } } diff --git a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts index 22bbd2e1a3..65fbfb9071 100644 --- a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Entity } from '@backstage/catalog-model'; +import { Entity, EntityName, getEntityName } from '@backstage/catalog-model'; import lodash from 'lodash'; import type { EntitiesCatalog } from './types'; @@ -34,17 +34,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog { return item ? lodash.cloneDeep(item) : undefined; } - async entityByName( - kind: string, - name: string, - namespace: string | undefined, - ): Promise { - const item = this._entities.find( - e => - kind === e.kind && - name === e.metadata.name && - namespace === e.metadata.namespace, - ); + async entityByName(name: EntityName): Promise { + const item = this._entities.find(e => { + const candidate = getEntityName(e); + return ( + name.kind.toLowerCase() === candidate.kind.toLowerCase() && + name.namespace.toLowerCase() === candidate.namespace.toLowerCase() && + name.name.toLowerCase() === candidate.name.toLowerCase() + ); + }); return item ? lodash.cloneDeep(item) : undefined; } diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index b0d3dde0fb..37618a2440 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity, Location } from '@backstage/catalog-model'; +import { Entity, EntityName, Location } from '@backstage/catalog-model'; import type { EntityFilters } from '../database'; // @@ -24,11 +24,7 @@ import type { EntityFilters } from '../database'; export type EntitiesCatalog = { entities(filters?: EntityFilters): Promise; entityByUid(uid: string): Promise; - entityByName( - kind: string, - namespace: string | undefined, - name: string, - ): Promise; + entityByName(name: EntityName): Promise; addOrUpdateEntity(entity: Entity, locationId?: string): Promise; removeEntityByUid(uid: string): Promise; }; diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index b86b4ef959..8a42b39cc5 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -149,24 +149,6 @@ describe('CommonDatabase', () => { ).rejects.toThrow(ConflictError); }); - it('rejects adding the almost-same-kind entity twice', async () => { - entityRequest.entity.kind = 'some-kind'; - await db.transaction(tx => db.addEntity(tx, entityRequest)); - entityRequest.entity.kind = 'SomeKind'; - await expect( - db.transaction(tx => db.addEntity(tx, entityRequest)), - ).rejects.toThrow(ConflictError); - }); - - it('rejects adding the almost-same-named entity twice', async () => { - entityRequest.entity.metadata.name = 'some-name'; - await db.transaction(tx => db.addEntity(tx, entityRequest)); - entityRequest.entity.metadata.name = 'SomeName'; - await expect( - db.transaction(tx => db.addEntity(tx, entityRequest)), - ).rejects.toThrow(ConflictError); - }); - it('rejects adding the almost-same-namespace entity twice', async () => { entityRequest.entity.metadata.namespace = undefined; await db.transaction(tx => db.addEntity(tx, entityRequest)); @@ -395,5 +377,90 @@ describe('CommonDatabase', () => { ]), ); }); + + it('can get all specific entities for matching filters case insensitively)', async () => { + const entities: Entity[] = [ + { apiVersion: 'A', kind: 'K1', metadata: { name: 'N' } }, + { + apiVersion: 'a', + kind: 'k2', + metadata: { name: 'n' }, + spec: { c: 'Some' }, + }, + { + apiVersion: 'a', + kind: 'k3', + metadata: { name: 'n' }, + spec: { c: null }, + }, + ]; + + await db.transaction(async tx => { + for (const entity of entities) { + await db.addEntity(tx, { entity }); + } + }); + + const rows = await db.transaction(async tx => + db.entities(tx, [ + { key: 'ApiVersioN', values: ['A'] }, + { key: 'spEc.C', values: [null, 'some'] }, + ]), + ); + + expect(rows.length).toEqual(3); + expect(rows).toEqual( + expect.arrayContaining([ + { + locationId: undefined, + entity: expect.objectContaining({ kind: 'K1' }), + }, + { + locationId: undefined, + entity: expect.objectContaining({ kind: 'k2' }), + }, + { + locationId: undefined, + entity: expect.objectContaining({ kind: 'k3' }), + }, + ]), + ); + }); + }); + + describe('entityByName', () => { + it('can get entities case insensitively', async () => { + const entities: Entity[] = [ + { + apiVersion: 'a', + kind: 'k1', + metadata: { name: 'n' }, + }, + { + apiVersion: 'B', + kind: 'K2', + metadata: { name: 'N', namespace: 'NS' }, + }, + ]; + + await db.transaction(async tx => { + for (const entity of entities) { + await db.addEntity(tx, { entity }); + } + }); + + const e1 = await db.transaction(async tx => + db.entityByName(tx, { kind: 'k1', namespace: 'default', name: 'n' }), + ); + expect(e1!.entity.metadata.name).toEqual('n'); + const e2 = await db.transaction(async tx => + db.entityByName(tx, { kind: 'k2', namespace: 'nS', name: 'n' }), + ); + expect(e2!.entity.metadata.name).toEqual('N'); + const e3 = await db.transaction(async tx => + db.entityByName(tx, { kind: 'unknown', namespace: 'nS', name: 'n' }), + ); + expect(e3).toBeUndefined(); + }); }); }); diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 4ad5362fb6..5327384593 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -22,7 +22,9 @@ import { import { Entity, EntityMeta, - entityMetaGeneratedFields, + EntityName, + ENTITY_DEFAULT_NAMESPACE, + ENTITY_META_GENERATED_FIELDS, generateEntityEtag, generateEntityUid, Location, @@ -50,7 +52,6 @@ import type { export class CommonDatabase implements Database { constructor( private readonly database: Knex, - private readonly normalize: (value: string) => string, private readonly logger: Logger, ) {} @@ -85,8 +86,6 @@ export class CommonDatabase implements Database { throw new InputError('May not specify generation for new entities'); } - await this.ensureNoSimilarNames(tx, request.entity); - const newEntity = lodash.cloneDeep(request.entity); newEntity.metadata = { ...newEntity.metadata, @@ -144,8 +143,6 @@ export class CommonDatabase implements Database { } } - await this.ensureNoSimilarNames(tx, request.entity); - // Store the updated entity; select on the old etag to ensure that we do // not lose to another writer const newRow = this.toEntityRow(request.locationId, request.entity); @@ -172,7 +169,7 @@ export class CommonDatabase implements Database { let builder = tx('entities'); for (const [indexU, filter] of (filters ?? []).entries()) { const index = Number(indexU); - const key = filter.key.replace('*', '%'); + const key = filter.key.toLowerCase().replace(/\*/g, '%'); const keyOp = filter.key.includes('*') ? 'like' : '='; let matchNulls = false; @@ -183,9 +180,9 @@ export class CommonDatabase implements Database { if (!value) { matchNulls = true; } else if (value.includes('*')) { - matchLike.push(value.replace('*', '%')); + matchLike.push(value.toLowerCase().replace(/\*/g, '%')); } else { - matchIn.push(value); + matchIn.push(value.toLowerCase()); } } @@ -219,16 +216,19 @@ export class CommonDatabase implements Database { return rows.map(row => this.toEntityResponse(row)); } - async entity( + async entityByName( txOpaque: unknown, - kind: string, - name: string, - namespace?: string, + name: EntityName, ): Promise { const tx = txOpaque as Knex.Transaction; const rows = await tx('entities') - .where({ kind, name, namespace: namespace || null }) + .whereRaw( + tx.raw( + 'LOWER(kind) = LOWER(?) AND LOWER(namespace) = LOWER(?) AND LOWER(name) = LOWER(?)', + [name.kind, name.namespace, name.name], + ), + ) .select(); if (rows.length !== 1) { @@ -240,11 +240,13 @@ export class CommonDatabase implements Database { async entityByUid( txOpaque: unknown, - id: string, + uid: string, ): Promise { const tx = txOpaque as Knex.Transaction; - const rows = await tx('entities').where({ id }).select(); + const rows = await tx('entities') + .where({ id: uid }) + .select(); if (rows.length !== 1) { return undefined; @@ -377,63 +379,28 @@ export class CommonDatabase implements Database { } } - private async ensureNoSimilarNames( - tx: Knex.Transaction, - data: Entity, - ): Promise { - const newKind = data.kind; - const newName = data.metadata.name; - const newNamespace = data.metadata.namespace; - const newKindNorm = this.normalize(newKind); - const newNameNorm = this.normalize(newName); - const newNamespaceNorm = this.normalize(newNamespace || ''); - - for (const item of await this.entities(tx)) { - if (data.metadata.uid === item.entity.metadata.uid) { - continue; - } - - const oldKind = item.entity.kind; - const oldName = item.entity.metadata.name; - const oldNamespace = item.entity.metadata.namespace; - const oldKindNorm = this.normalize(oldKind); - const oldNameNorm = this.normalize(oldName); - const oldNamespaceNorm = this.normalize(oldNamespace || ''); - - if ( - oldKindNorm === newKindNorm && - oldNameNorm === newNameNorm && - oldNamespaceNorm === newNamespaceNorm - ) { - // Only throw if things were actually different - for completely equal - // things, we let the database handle the conflict - if ( - oldKind !== newKind || - oldName !== newName || - oldNamespace !== newNamespace - ) { - const message = `Kind, namespace, name are too similar to an existing entity`; - throw new ConflictError(message); - } - } - } - } - private toEntityRow( locationId: string | undefined, entity: Entity, ): DbEntitiesRow { + const lowerKind = entity.kind.toLowerCase(); + const lowerNamespace = ( + entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE + ).toLowerCase(); + const lowerName = entity.metadata.name.toLowerCase(); + return { id: entity.metadata.uid!, location_id: locationId || null, etag: entity.metadata.etag!, generation: entity.metadata.generation!, + full_name: `${lowerKind}:${lowerNamespace}/${lowerName}`, api_version: entity.apiVersion, kind: entity.kind, name: entity.metadata.name, - namespace: entity.metadata.namespace || null, + namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE, metadata: JSON.stringify( - lodash.omit(entity.metadata, ...entityMetaGeneratedFields), + lodash.omit(entity.metadata, ...ENTITY_META_GENERATED_FIELDS), ), spec: entity.spec ? JSON.stringify(entity.spec) : null, }; diff --git a/plugins/catalog-backend/src/database/DatabaseManager.ts b/plugins/catalog-backend/src/database/DatabaseManager.ts index ce91cc737f..afff254097 100644 --- a/plugins/catalog-backend/src/database/DatabaseManager.ts +++ b/plugins/catalog-backend/src/database/DatabaseManager.ts @@ -15,7 +15,6 @@ */ import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common'; -import { makeValidator } from '@backstage/catalog-model'; import Knex from 'knex'; import { Logger } from 'winston'; import { CommonDatabase } from './CommonDatabase'; @@ -28,12 +27,10 @@ const migrationsDir = resolvePackagePath( export type CreateDatabaseOptions = { logger: Logger; - fieldNormalizer: (value: string) => string; }; const defaultOptions: CreateDatabaseOptions = { logger: getVoidLogger(), - fieldNormalizer: makeValidator().normalizeEntityName, }; export class DatabaseManager { @@ -44,8 +41,8 @@ export class DatabaseManager { await knex.migrate.latest({ directory: migrationsDir, }); - const { logger, fieldNormalizer } = { ...defaultOptions, ...options }; - return new CommonDatabase(knex, fieldNormalizer, logger); + const { logger } = { ...defaultOptions, ...options }; + return new CommonDatabase(knex, logger); } public static async createInMemoryDatabase( @@ -74,7 +71,7 @@ export class DatabaseManager { await knex.migrate.latest({ directory: migrationsDir, }); - const { logger, fieldNormalizer } = defaultOptions; - return new CommonDatabase(knex, fieldNormalizer, logger); + const { logger } = defaultOptions; + return new CommonDatabase(knex, logger); } } diff --git a/plugins/catalog-backend/src/database/search.test.ts b/plugins/catalog-backend/src/database/search.test.ts index 38a2d40e74..fad17da733 100644 --- a/plugins/catalog-backend/src/database/search.test.ts +++ b/plugins/catalog-backend/src/database/search.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Entity } from '@backstage/catalog-model'; +import { ENTITY_DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model'; import { buildEntitySearch, visitEntityPart } from './search'; import type { DbEntitiesSearchRow } from './types'; @@ -95,6 +95,16 @@ describe('search', () => { { entity_id: 'eid', key: 'root.list.a', value: '2' }, ]); }); + + it('emits lowercase version of keys and values', () => { + const input = { theRoot: { listItems: [{ a: 'One' }, { a: 2 }] } }; + const output: DbEntitiesSearchRow[] = []; + visitEntityPart('eid', '', input, output); + expect(output).toEqual([ + { entity_id: 'eid', key: 'theroot.listitems.a', value: 'one' }, + { entity_id: 'eid', key: 'theroot.listitems.a', value: '2' }, + ]); + }); }); describe('buildEntitySearch', () => { @@ -108,47 +118,14 @@ describe('search', () => { { entity_id: 'eid', key: 'metadata.name', value: 'n' }, { entity_id: 'eid', key: 'metadata.namespace', value: null }, { entity_id: 'eid', key: 'metadata.uid', value: null }, - { entity_id: 'eid', key: 'apiVersion', value: 'a' }, + { + entity_id: 'eid', + key: 'metadata.namespace', + value: ENTITY_DEFAULT_NAMESPACE, + }, + { entity_id: 'eid', key: 'apiversion', value: 'a' }, { entity_id: 'eid', key: 'kind', value: 'b' }, - { entity_id: 'eid', key: 'name', value: 'n' }, - { entity_id: 'eid', key: 'namespace', value: null }, - { entity_id: 'eid', key: 'uid', value: null }, ]); }); - - it('adds prefix-stripped versions', () => { - const input: Entity = { - apiVersion: 'a', - kind: 'b', - metadata: { - name: 'name', - labels: { - lbl: 'lbl', - }, - annotations: { - ann: 'ann', - }, - }, - spec: { - sub: { - spc: 'spc', - }, - }, - }; - expect(buildEntitySearch('eid', input)).toStrictEqual( - expect.arrayContaining([ - { entity_id: 'eid', key: 'metadata.name', value: 'name' }, - { entity_id: 'eid', key: 'name', value: 'name' }, - { entity_id: 'eid', key: 'metadata.labels.lbl', value: 'lbl' }, - { entity_id: 'eid', key: 'labels.lbl', value: 'lbl' }, - { entity_id: 'eid', key: 'lbl', value: 'lbl' }, - { entity_id: 'eid', key: 'metadata.annotations.ann', value: 'ann' }, - { entity_id: 'eid', key: 'annotations.ann', value: 'ann' }, - { entity_id: 'eid', key: 'ann', value: 'ann' }, - { entity_id: 'eid', key: 'spec.sub.spc', value: 'spc' }, - { entity_id: 'eid', key: 'sub.spc', value: 'spc' }, - ]), - ); - }); }); }); diff --git a/plugins/catalog-backend/src/database/search.ts b/plugins/catalog-backend/src/database/search.ts index c14acb6661..bbed2fac47 100644 --- a/plugins/catalog-backend/src/database/search.ts +++ b/plugins/catalog-backend/src/database/search.ts @@ -14,19 +14,10 @@ * limitations under the License. */ -import type { Entity } from '@backstage/catalog-model'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import type { DbEntitiesSearchRow } from './types'; -// Search entries that start with these prefixes, also get a shorthand without -// that prefix -const SHORTHAND_KEY_PREFIXES = [ - 'metadata.', - 'metadata.labels.', - 'metadata.annotations.', - 'spec.', -]; - -// These are exluded in the generic loop, either because they do not make sense +// These are excluded in the generic loop, either because they do not make sense // to index, or because they are special-case always inserted whether they are // null or not const SPECIAL_KEYS = [ @@ -42,7 +33,7 @@ function toValue(current: any): string | null { return null; } - return String(current); + return String(current).toLowerCase(); } // Helper for iterating through a nested structure and outputting a list of @@ -106,7 +97,12 @@ export function visitEntityPart( // object for (const [key, value] of Object.entries(current)) { - visitEntityPart(entityId, path ? `${path}.${key}` : key, value, output); + visitEntityPart( + entityId, + (path ? `${path}.${key}` : key).toLowerCase(), + value, + output, + ); } } @@ -141,17 +137,18 @@ export function buildEntitySearch( }, ]; + // Namespace not specified has the default value "default", so we want to + // match on that as well + if (!entity.metadata.namespace) { + result.push({ + entity_id: entityId, + key: 'metadata.namespace', + value: toValue(ENTITY_DEFAULT_NAMESPACE), + }); + } + // Visit the entire structure recursively visitEntityPart(entityId, '', entity, result); - // Generate shorthands for fields directly under some common collections - for (const row of result.slice()) { - for (const stripPrefix of SHORTHAND_KEY_PREFIXES) { - if (row.key.startsWith(stripPrefix)) { - result.push({ ...row, key: row.key.substr(stripPrefix.length) }); - } - } - } - return result; } diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 0537b87800..c0f5777743 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Entity, Location } from '@backstage/catalog-model'; +import type { Entity, EntityName, Location } from '@backstage/catalog-model'; export type DbEntitiesRow = { id: string; @@ -25,6 +25,7 @@ export type DbEntitiesRow = { namespace: string | null; etag: string; generation: number; + full_name: string; metadata: string; spec: string | null; }; @@ -129,11 +130,9 @@ export type Database = { entities(tx: unknown, filters?: EntityFilters): Promise; - entity( + entityByName( tx: unknown, - kind: string, - name: string, - namespace?: string, + name: EntityName, ): Promise; entityByUid(tx: unknown, uid: string): Promise; diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.ts index e964524bc7..e82699f526 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.ts @@ -74,11 +74,11 @@ export class CatalogRulesEnforcer { * - allow: [Component, API] * * locations: - * - type: github + * - type: url * target: https://github.com/org/repo/blob/master/users.yaml * rules: * - allow: [User, Group] - * - type: github + * - type: url * target: https://github.com/org/repo/blob/master/systems.yaml * rules: * - allow: [System] diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index de68a16e9c..32fcc27909 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -15,7 +15,12 @@ */ import { getVoidLogger } from '@backstage/backend-common'; -import { Entity, Location, LocationSpec } from '@backstage/catalog-model'; +import { + Entity, + ENTITY_DEFAULT_NAMESPACE, + Location, + LocationSpec, +} from '@backstage/catalog-model'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { LocationUpdateStatus } from '../catalog/types'; import { DatabaseLocationUpdateLogStatus } from '../database/types'; @@ -200,12 +205,11 @@ describe('HigherOrderOperations', () => { target: 'thing', }); expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith( - 1, - 'Component', - undefined, - 'c1', - ); + expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(1, { + kind: 'Component', + namespace: ENTITY_DEFAULT_NAMESPACE, + name: 'c1', + }); expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1); expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith( 1, diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 0224dc7f1d..571602b966 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -18,8 +18,10 @@ import { InputError } from '@backstage/backend-common'; import { Entity, entityHasChanges, + getEntityName, Location, LocationSpec, + serializeEntityRef, } from '@backstage/catalog-model'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; @@ -158,30 +160,23 @@ export class HigherOrderOperations implements HigherOrderOperation { ); } + this.logger.info( + `Read ${readerOutput.entities.length} entities from location ${location.type} ${location.target}`, + ); + + const startTimestamp = process.hrtime(); for (const item of readerOutput.entities) { const { entity } = item; - this.logger.debug( - `Read entity kind="${entity.kind}" name="${ - entity.metadata.name - }" namespace="${entity.metadata.namespace || ''}"`, - ); - try { const previous = await this.entitiesCatalog.entityByName( - entity.kind, - entity.metadata.namespace, - entity.metadata.name, + getEntityName(entity), ); if (!previous) { - this.logger.debug(`No such entity found, adding`); await this.entitiesCatalog.addOrUpdateEntity(entity, location.id); } else if (entityHasChanges(previous, entity)) { - this.logger.debug(`Different from existing entity, updating`); await this.entitiesCatalog.addOrUpdateEntity(entity, location.id); - } else { - this.logger.debug(`Equal to existing entity, skipping update`); } await this.locationsCatalog.logUpdateSuccess( @@ -189,10 +184,8 @@ export class HigherOrderOperations implements HigherOrderOperation { entity.metadata.name, ); } catch (error) { - this.logger.debug( - `Failed refresh of entity kind="${entity.kind}" name="${ - entity.metadata.name - }" namespace="${entity.metadata.namespace || ''}", ${error}`, + this.logger.info( + `Failed refresh of entity ${serializeEntityRef(entity)}, ${error}`, ); await this.locationsCatalog.logUpdateFailure( @@ -202,5 +195,11 @@ export class HigherOrderOperations implements HigherOrderOperation { ); } } + + const delta = process.hrtime(startTimestamp); + const durationMs = ((delta[0] * 1e9 + delta[1]) / 1e6).toFixed(1); + this.logger.info( + `Wrote ${readerOutput.entities.length} entities from location ${location.type} ${location.target} in ${durationMs} seconds`, + ); } } diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 8da6e20ab3..fcc608eb51 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -14,27 +14,33 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; -import { Config, ConfigReader } from '@backstage/config'; +import { getVoidLogger, UrlReader } from '@backstage/backend-common'; import { Entity, EntityPolicies, EntityPolicy, + ENTITY_DEFAULT_NAMESPACE, LocationSpec, } from '@backstage/catalog-model'; +import { Config, ConfigReader } from '@backstage/config'; import { Logger } from 'winston'; +import { CatalogRulesEnforcer } from './CatalogRules'; import { AnnotateLocationEntityProcessor } from './processors/AnnotateLocationEntityProcessor'; +import { ApiDefinitionAtLocationProcessor } from './processors/ApiDefinitionAtLocationProcessor'; +import { AzureApiReaderProcessor } from './processors/AzureApiReaderProcessor'; +import { BitbucketApiReaderProcessor } from './processors/BitbucketApiReaderProcessor'; +import { CodeOwnersProcessor } from './processors/CodeOwnersProcessor'; import { EntityPolicyProcessor } from './processors/EntityPolicyProcessor'; import { FileReaderProcessor } from './processors/FileReaderProcessor'; +import { GithubOrgReaderProcessor } from './processors/GithubOrgReaderProcessor'; import { GithubReaderProcessor } from './processors/GithubReaderProcessor'; import { GitlabApiReaderProcessor } from './processors/GitlabApiReaderProcessor'; import { GitlabReaderProcessor } from './processors/GitlabReaderProcessor'; -import { BitbucketApiReaderProcessor } from './processors/BitbucketApiReaderProcessor'; -import { AzureApiReaderProcessor } from './processors/AzureApiReaderProcessor'; -import { UrlReaderProcessor } from './processors/UrlReaderProcessor'; +import { LdapOrgReaderProcessor } from './processors/LdapOrgReaderProcessor'; import { LocationRefProcessor } from './processors/LocationEntityProcessor'; -import { StaticLocationProcessor } from './processors/StaticLocationProcessor'; +import { PlaceholderProcessor } from './processors/PlaceholderProcessor'; import * as result from './processors/results'; +import { StaticLocationProcessor } from './processors/StaticLocationProcessor'; import { LocationProcessor, LocationProcessorDataResult, @@ -44,14 +50,15 @@ import { LocationProcessorLocationResult, LocationProcessorResult, } from './processors/types'; +import { UrlReaderProcessor } from './processors/UrlReaderProcessor'; import { YamlProcessor } from './processors/YamlProcessor'; import { LocationReader, ReadLocationResult } from './types'; -import { CatalogRulesEnforcer } from './CatalogRules'; // The max amount of nesting depth of generated work items const MAX_DEPTH = 10; type Options = { + reader: UrlReader; logger?: Logger; config?: Config; processors?: LocationProcessor[]; @@ -67,6 +74,7 @@ export class LocationReaders implements LocationReader { static defaultProcessors(options: { logger: Logger; + reader: UrlReader; config?: Config; entityPolicy?: EntityPolicy; }): LocationProcessor[] { @@ -75,16 +83,51 @@ export class LocationReaders implements LocationReader { config = new ConfigReader({}, 'missing-config'), entityPolicy = new EntityPolicies(), } = options; + + // TODO(Rugvip): These are added for backwards compatibility if config exists + // The idea is to have everyone migrate from using the old processors to the new + // integration config driven UrlReaders. In an upcoming release we can then completely + // remove support for the old processors, but still keep handling the deprecated location + // types for a while, but with a warning. + const oldProcessors = []; + const pc = config.getOptionalConfig('catalog.processors'); + if (pc?.has('github')) { + logger.warn( + `Using deprecated configuration for catalog.processors.github, move to using integrations.github instead`, + ); + oldProcessors.push(GithubReaderProcessor.fromConfig(config, logger)); + } + if (pc?.has('gitlabApi')) { + logger.warn( + `Using deprecated configuration for catalog.processors.gitlabApi, move to using integrations.gitlab instead`, + ); + oldProcessors.push(new GitlabApiReaderProcessor(config)); + oldProcessors.push(new GitlabReaderProcessor()); + } + if (pc?.has('bitbucketApi')) { + logger.warn( + `Using deprecated configuration for catalog.processors.bitbucketApi, move to using integrations.bitbucket instead`, + ); + oldProcessors.push(new BitbucketApiReaderProcessor(config)); + } + if (pc?.has('azureApi')) { + logger.warn( + `Using deprecated configuration for catalog.processors.azureApi, move to using integrations.azure instead`, + ); + oldProcessors.push(new AzureApiReaderProcessor(config)); + } + return [ StaticLocationProcessor.fromConfig(config), new FileReaderProcessor(), - GithubReaderProcessor.fromConfig(config, logger), - new GitlabApiReaderProcessor(config), - new GitlabReaderProcessor(), - new BitbucketApiReaderProcessor(config), - new AzureApiReaderProcessor(config), - new UrlReaderProcessor(), + ...oldProcessors, + GithubOrgReaderProcessor.fromConfig(config, { logger }), + LdapOrgReaderProcessor.fromConfig(config, { logger }), + new UrlReaderProcessor(options), new YamlProcessor(), + PlaceholderProcessor.default(), + new CodeOwnersProcessor(), + new ApiDefinitionAtLocationProcessor(), new EntityPolicyProcessor(entityPolicy), new LocationRefProcessor(), new AnnotateLocationEntityProcessor(), @@ -94,7 +137,8 @@ export class LocationReaders implements LocationReader { constructor({ logger = getVoidLogger(), config, - processors = LocationReaders.defaultProcessors({ logger, config }), + reader, + processors = LocationReaders.defaultProcessors({ logger, reader, config }), }: Options) { this.logger = logger; this.processors = processors; @@ -157,10 +201,6 @@ export class LocationReaders implements LocationReader { item: LocationProcessorLocationResult, emit: LocationProcessorEmit, ) { - this.logger.debug( - `Reading location ${item.location.type} ${item.location.target} optional=${item.optional}`, - ); - for (const processor of this.processors) { if (processor.readLocation) { try { @@ -172,22 +212,20 @@ export class LocationReaders implements LocationReader { } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while reading location ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); + this.logger.warn(message); } } } const message = `No processor was able to read location ${item.location.type} ${item.location.target}`; emit(result.inputError(item.location, message)); + this.logger.warn(message); } private async handleData( item: LocationProcessorDataResult, emit: LocationProcessorEmit, ) { - this.logger.debug( - `Parsing data from location ${item.location.type} ${item.location.target} (${item.data.byteLength} bytes)`, - ); - for (const processor of this.processors) { if (processor.parseData) { try { @@ -197,6 +235,7 @@ export class LocationReaders implements LocationReader { } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while parsing ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); + this.logger.warn(message); } } } @@ -209,19 +248,27 @@ export class LocationReaders implements LocationReader { item: LocationProcessorEntityResult, emit: LocationProcessorEmit, ): Promise { - this.logger.debug( - `Got entity at location ${item.location.type} ${item.location.target}, ${item.entity.apiVersion} ${item.entity.kind}`, - ); - let current = item.entity; for (const processor of this.processors) { if (processor.processEntity) { try { - current = await processor.processEntity(current, item.location, emit); + current = await processor.processEntity( + current, + item.location, + emit, + this.readLocation.bind(this), + ); } catch (e) { - const message = `Processor ${processor.constructor.name} threw an error while processing entity at ${item.location.type} ${item.location.target}, ${e}`; + // Construct the name carefully, if we got validation errors we do + // not want to crash here due to missing metadata or so + const namespace = !current.metadata + ? '' + : current.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE; + const name = !current.metadata ? '' : current.metadata.name; + const message = `Processor ${processor.constructor.name} threw an error while processing entity ${current.kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); + this.logger.warn(message); } } } @@ -244,8 +291,45 @@ export class LocationReaders implements LocationReader { } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while handling another error at ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); + this.logger.warn(message); } } } } + + private async readLocation(location: LocationSpec): Promise { + let data: Buffer | undefined = undefined; + let error: Error | undefined = undefined; + + await this.handleLocation( + { + type: 'location', + location, + optional: false, + }, + output => { + if (output.type === 'error' && !error) { + error = output.error; + } else if (output.type === 'data') { + if (data) { + if (!error) { + error = new Error( + 'More than one piece of data loaded unexpectedly', + ); + } + } else { + data = output.data; + } + } + }, + ); + + if (error) { + throw error; + } else if (!data) { + throw new Error('No data loaded'); + } + + return data; + } } diff --git a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts new file mode 100644 index 0000000000..6ae6562339 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.test.ts @@ -0,0 +1,93 @@ +import { ApiEntity, Entity, LocationSpec } from '@backstage/catalog-model'; +/* + * 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 { ApiDefinitionAtLocationProcessor } from './ApiDefinitionAtLocationProcessor'; + +describe('ApiDefinitionAtLocationProcessor', () => { + let processor: ApiDefinitionAtLocationProcessor; + let entity: Entity; + let location: LocationSpec; + + beforeEach(() => { + processor = new ApiDefinitionAtLocationProcessor(); + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { + name: 'test', + }, + spec: { + lifecycle: 'production', + owner: 'info@example.com', + type: 'openapi', + definition: 'Hello', + }, + }; + location = { + type: 'url', + target: `http://example.com/api.yaml`, + }; + }); + + it('should skip entities without annotation', async () => { + const read = jest.fn().mockRejectedValue(new Error('boo')); + + const generated = (await processor.processEntity( + entity, + location, + () => {}, + read, + )) as ApiEntity; + + expect(generated.spec.definition).toBe('Hello'); + }); + + it('should load from location', async () => { + entity.metadata.annotations = { + 'backstage.io/definition-at-location': + 'url:http://example.com/openapi.yaml', + }; + + const read = jest.fn().mockResolvedValue(Buffer.from('Hello')); + + const generated = (await processor.processEntity( + entity, + location, + () => {}, + read, + )) as ApiEntity; + + expect(generated.spec.definition).toBe('Hello'); + expect(read.mock.calls[0][0]).toStrictEqual({ + type: 'url', + target: 'http://example.com/openapi.yaml', + }); + }); + + it('should throw errors while loading', async () => { + entity.metadata.annotations = { + 'backstage.io/definition-at-location': 'missing', + }; + + const read = jest + .fn() + .mockRejectedValue(new Error('Failed to load location')); + + await expect( + processor.processEntity(entity, location, () => {}, read), + ).rejects.toThrow('Failed to load location'); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.ts new file mode 100644 index 0000000000..e64d112e9c --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ApiDefinitionAtLocationProcessor.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 { ApiEntity, Entity, LocationSpec } from '@backstage/catalog-model'; +import { + LocationProcessor, + LocationProcessorEmit, + LocationProcessorRead, +} from './types'; + +const DEFINITION_AT_LOCATION_ANNOTATION = 'backstage.io/definition-at-location'; + +export class ApiDefinitionAtLocationProcessor implements LocationProcessor { + async processEntity( + entity: Entity, + _location: LocationSpec, + _emit: LocationProcessorEmit, + read: LocationProcessorRead, + ): Promise { + if ( + entity.kind !== 'API' || + !entity.metadata.annotations || + !entity.metadata.annotations[DEFINITION_AT_LOCATION_ANNOTATION] + ) { + return entity; + } + + const reference = + entity.metadata.annotations[DEFINITION_AT_LOCATION_ANNOTATION]; + const { type, target } = extractReference(reference); + const data = await read({ type, target }); + const definition = data.toString(); + const apiEntity = entity as ApiEntity; + apiEntity.spec.definition = definition; + + return entity; + } +} + +function extractReference(reference: string): { type: string; target: string } { + const delimiterIndex = reference.indexOf(':'); + const type = reference.slice(0, delimiterIndex); + const target = reference.slice(delimiterIndex + 1); + + return { type, target }; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts deleted file mode 100644 index 9b234e3e75..0000000000 --- a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts +++ /dev/null @@ -1,122 +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 { AzureApiReaderProcessor } from './AzureApiReaderProcessor'; -import { ConfigReader } from '@backstage/config'; - -describe('AzureApiReaderProcessor', () => { - const createConfig = (token: string | undefined) => - ConfigReader.fromConfigs([ - { - context: '', - data: { - catalog: { - processors: { - azureApi: { - privateToken: token, - }, - }, - }, - }, - }, - ]); - - it('should build raw api', () => { - const processor = new AzureApiReaderProcessor(createConfig(undefined)); - const tests = [ - { - target: - 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', - url: new URL( - 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', - ), - err: undefined, - }, - { - target: - 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', - url: new URL( - 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', - ), - err: undefined, - }, - { - target: 'https://api.com/a/b/blob/master/path/to/c.yaml', - url: null, - err: - 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', - }, - { - target: 'com/a/b/blob/master/path/to/c.yaml', - url: null, - err: - 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', - }, - ]; - - for (const test of tests) { - if (test.err) { - expect(() => processor.buildRawUrl(test.target)).toThrowError(test.err); - } else if (test.url) { - expect(processor.buildRawUrl(test.target).toString()).toEqual( - test.url.toString(), - ); - } else { - throw new Error( - 'This should not have happened. Either err or url should have matched.', - ); - } - } - }); - - it('should return request options', () => { - const tests = [ - { - token: '0123456789', - expect: { - headers: { - Authorization: 'Basic OjAxMjM0NTY3ODk=', - }, - }, - }, - { - token: '', - expect: { - headers: {}, - }, - err: - "Invalid type in config for key 'catalog.processors.azureApi.privateToken' in '', got empty-string, wanted string", - }, - { - token: undefined, - expect: { - headers: {}, - }, - }, - ]; - - for (const test of tests) { - if (test.err) { - expect( - () => new AzureApiReaderProcessor(createConfig(test.token)), - ).toThrowError(test.err); - } else { - const processor = new AzureApiReaderProcessor(createConfig(test.token)); - expect(processor.getRequestOptions()).toEqual(test.expect); - } - } - }); -}); diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts index 03ff30ea69..2a15c18c84 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts @@ -20,6 +20,11 @@ import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; import { Config } from '@backstage/config'; +// *********************************************************************** +// * NOTE: This has been replaced by packages/backend-common/src/reading * +// * Don't implement new functionality here as this file will be removed * +// *********************************************************************** + export class AzureApiReaderProcessor implements LocationProcessor { private privateToken: string; @@ -107,8 +112,7 @@ export class AzureApiReaderProcessor implements LocationProcessor { srcKeyword !== '_git' || repoName === '' || path === '' || - ref === '' || - !path.match(/\.yaml$/) + ref === '' ) { throw new Error('Wrong Azure Devops URL or Invalid file path'); } diff --git a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.test.ts deleted file mode 100644 index 953f0703be..0000000000 --- a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.test.ts +++ /dev/null @@ -1,161 +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 { BitbucketApiReaderProcessor } from './BitbucketApiReaderProcessor'; -import { ConfigReader } from '@backstage/config'; - -describe('BitbucketApiReaderProcessor', () => { - const createConfig = ( - username: string | undefined, - appPassword: string | undefined, - ) => - ConfigReader.fromConfigs([ - { - context: '', - data: { - catalog: { - processors: { - bitbucketApi: { - username: username, - appPassword: appPassword, - }, - }, - }, - }, - }, - ]); - - it('should build raw api', () => { - const processor = new BitbucketApiReaderProcessor( - createConfig(undefined, undefined), - ); - - const tests = [ - { - target: - 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', - url: new URL( - 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', - ), - err: undefined, - }, - { - target: 'https://api.com/a/b/blob/master/path/to/c.yaml', - url: null, - err: - 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Bitbucket URL or Invalid file path', - }, - { - target: 'com/a/b/blob/master/path/to/c.yaml', - url: null, - err: - 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', - }, - ]; - - for (const test of tests) { - if (test.err) { - expect(() => processor.buildRawUrl(test.target)).toThrowError(test.err); - } else if (test.url) { - expect(processor.buildRawUrl(test.target).toString()).toEqual( - test.url.toString(), - ); - } else { - throw new Error( - 'This should not have happened. Either err or url should have matched.', - ); - } - } - }); - - it('should return request options', () => { - const tests = [ - { - username: '', - password: '', - expect: { - headers: {}, - }, - err: - "Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string", - }, - { - username: 'only-user-provided', - password: '', - expect: { - headers: {}, - }, - err: - "Invalid type in config for key 'catalog.processors.bitbucketApi.appPassword' in '', got empty-string, wanted string", - }, - { - username: '', - password: 'only-password-provided', - expect: { - headers: {}, - }, - err: - "Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string", - }, - { - username: 'some-user', - password: 'my-secret', - expect: { - headers: { - Authorization: 'Basic c29tZS11c2VyOm15LXNlY3JldA==', - }, - }, - }, - { - username: undefined, - password: undefined, - expect: { - headers: {}, - }, - }, - { - username: 'only-user-provided', - password: undefined, - expect: { - headers: {}, - }, - }, - { - username: undefined, - password: 'only-password-provided', - expect: { - headers: {}, - }, - }, - ]; - - for (const test of tests) { - if (test.err) { - expect( - () => - new BitbucketApiReaderProcessor( - createConfig(test.username, test.password), - ), - ).toThrowError(test.err); - } else { - const processor = new BitbucketApiReaderProcessor( - createConfig(test.username, test.password), - ); - expect(processor.getRequestOptions()).toEqual(test.expect); - } - } - }); -}); diff --git a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts index 97ecf0cd1f..b3720a5b76 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts @@ -20,6 +20,11 @@ import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; import { Config } from '@backstage/config'; +// *********************************************************************** +// * NOTE: This has been replaced by packages/backend-common/src/reading * +// * Don't implement new functionality here as this file will be removed * +// *********************************************************************** + export class BitbucketApiReaderProcessor implements LocationProcessor { private username: string; private password: string; @@ -106,8 +111,7 @@ export class BitbucketApiReaderProcessor implements LocationProcessor { empty !== '' || userOrOrg === '' || repoName === '' || - srcKeyword !== 'src' || - !restOfPath.join('/').match(/\.yaml$/) + srcKeyword !== 'src' ) { throw new Error('Wrong Bitbucket URL or Invalid file path'); } diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts new file mode 100644 index 0000000000..29f94dd0d7 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts @@ -0,0 +1,279 @@ +/* + * 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 { LocationSpec } from '@backstage/catalog-model'; +import { CodeOwnersEntry } from 'codeowners-utils'; +import { + buildCodeOwnerLocation, + buildUrl, + CodeOwnersProcessor, + findPrimaryCodeOwner, + findRawCodeOwners, + normalizeCodeOwner, + parseCodeOwners, + resolveCodeOwner, +} from './CodeOwnersProcessor'; + +describe(CodeOwnersProcessor, () => { + const mockLocation = ({ + basePath = '', + type = 'github', + } = {}): LocationSpec => ({ + type, + target: `https://github.com/spotify/backstage/blob/master/${basePath}catalog-info.yaml`, + }); + + const mockReadLocation = (basePath = '') => ({ + type: 'github', + target: `https://github.com/spotify/backstage/blob/master/${basePath}CODEOWNERS`, + }); + + const mockGitUri = (codeOwnersPath: string = '') => { + return { + source: 'github.com', + owner: 'spotify', + name: 'backstage', + codeOwnersPath, + }; + }; + + const mockCodeOwnersText = () => ` +# https://help.github.com/articles/about-codeowners/ +* @spotify/backstage-core @acme/team-foo +/plugins/techdocs @spotify/techdocs-core + `; + + const mockCodeOwners = (): CodeOwnersEntry[] => { + return [ + { + pattern: '/plugins/techdocs', + owners: ['@spotify/techdocs-core'], + }, + { pattern: '*', owners: ['@spotify/backstage-core', '@acme/team-foo'] }, + ]; + }; + + const mockReadResult = ({ + error = undefined, + data = undefined, + }: { + error?: string; + data?: string; + } = {}) => { + if (error) { + throw Error(error); + } + return data; + }; + + describe(buildUrl, () => { + it.each([['azure.com'], ['dev.azure.com']])( + 'should throw not implemented error', + source => { + expect(() => buildUrl({ ...mockGitUri(), source })).toThrow(); + }, + ); + + it('should build github.com url', () => { + expect( + buildUrl({ + ...mockGitUri(), + codeOwnersPath: '/.github/CODEOWNERS', + }), + ).toBe( + 'https://github.com/spotify/backstage/blob/master/.github/CODEOWNERS', + ); + }); + }); + + describe(buildCodeOwnerLocation, () => { + it('should builds a location spec to the codeowners', () => { + expect( + buildCodeOwnerLocation(mockLocation(), '/docs/CODEOWNERS'), + ).toEqual({ + type: 'github', + target: + 'https://github.com/spotify/backstage/blob/master/docs/CODEOWNERS', + }); + }); + + it('should handle nested paths from original location spec', () => { + expect( + buildCodeOwnerLocation( + mockLocation({ basePath: 'packages/foo/' }), + '/CODEOWNERS', + ), + ).toEqual({ + type: 'github', + target: 'https://github.com/spotify/backstage/blob/master/CODEOWNERS', + }); + }); + }); + + describe(parseCodeOwners, () => { + it('should parse the codeowners file', () => { + expect(parseCodeOwners(mockCodeOwnersText())).toEqual(mockCodeOwners()); + }); + }); + + describe(normalizeCodeOwner, () => { + it('should remove org from org/team format', () => { + expect(normalizeCodeOwner('@acme/foo')).toBe('foo'); + }); + + it('should return username from email format', () => { + expect(normalizeCodeOwner('foo@acme.com')).toBe('foo'); + }); + + it.each([['acme/foo'], ['dacme/foo']])( + 'should return string everything else', + owner => { + expect(normalizeCodeOwner(owner)).toBe(owner); + }, + ); + }); + + describe(findPrimaryCodeOwner, () => { + it('should return the primary owner', () => { + expect(findPrimaryCodeOwner(mockCodeOwners())).toBe('backstage-core'); + }); + }); + + describe(findRawCodeOwners, () => { + it('should return found codeowner', async () => { + const ownersText = mockCodeOwnersText(); + const read = jest + .fn() + .mockResolvedValue(mockReadResult({ data: ownersText })); + const result = await findRawCodeOwners(mockLocation(), read); + expect(result).toEqual(ownersText); + }); + + it('should raise error when no codeowner', async () => { + const read = jest.fn().mockRejectedValue(mockReadResult()); + + await expect( + findRawCodeOwners(mockLocation(), read), + ).rejects.toBeInstanceOf(Error); + }); + + it('should look at known codeowner locations', async () => { + const ownersText = mockCodeOwnersText(); + const read = jest + .fn() + .mockImplementationOnce(() => mockReadResult({ error: 'foo' })) + .mockImplementationOnce(() => mockReadResult({ error: 'bar' })) + .mockResolvedValue(mockReadResult({ data: ownersText })); + + const result = await findRawCodeOwners(mockLocation(), read); + + expect(read.mock.calls.length).toBe(3); + expect(read.mock.calls[0]).toEqual([mockReadLocation('.github/')]); + expect(read.mock.calls[1]).toEqual([mockReadLocation('')]); + expect(read.mock.calls[2]).toEqual([mockReadLocation('docs/')]); + expect(result).toEqual(ownersText); + }); + }); + + describe(resolveCodeOwner, () => { + it('should return found codeowner', async () => { + const read = jest + .fn() + .mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() })); + const owner = await resolveCodeOwner(mockLocation(), read); + expect(owner).toBe('backstage-core'); + }); + + it('should raise an error when no codeowner', async () => { + const read = jest + .fn() + .mockImplementation(() => mockReadResult({ error: 'error: foo' })); + + await expect( + resolveCodeOwner(mockLocation(), read), + ).rejects.toBeInstanceOf(Error); + }); + }); + + describe(CodeOwnersProcessor, () => { + const setupTest = ({ kind = 'Component', spec = {} } = {}) => { + const entity = { kind, spec }; + const processor = new CodeOwnersProcessor(); + const read = jest + .fn() + .mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() })); + + return { entity, processor, read }; + }; + + it('should not modify existing owner', async () => { + const { entity, processor } = setupTest({ + spec: { owner: '@acme/foo-team' }, + }); + + const result = await processor.processEntity( + entity as any, + mockLocation(), + null as any, + null as any, + ); + + expect(result).toEqual(entity); + }); + + it('should ignore url locations', async () => { + const { entity, processor } = setupTest(); + + const result = await processor.processEntity( + entity as any, + mockLocation({ type: 'url' }), + null as any, + null as any, + ); + + expect(result).toEqual(entity); + }); + + it('should ignore invalid kinds', async () => { + const { entity, processor } = setupTest({ kind: 'Group' }); + + const result = await processor.processEntity( + entity as any, + mockLocation(), + null as any, + null as any, + ); + + expect(result).toEqual(entity); + }); + + it('should set owner from codeowner', async () => { + const { entity, processor, read } = setupTest(); + + const result = await processor.processEntity( + entity as any, + mockLocation(), + null as any, + read, + ); + + expect(result).toEqual({ + ...entity, + spec: { owner: 'backstage-core' }, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts new file mode 100644 index 0000000000..c875bdc799 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts @@ -0,0 +1,162 @@ +/* + * 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, LocationSpec } from '@backstage/catalog-model'; +import { + LocationProcessor, + LocationProcessorEmit, + LocationProcessorRead, +} from './types'; +import * as codeowners from 'codeowners-utils'; +import { CodeOwnersEntry } from 'codeowners-utils'; +import parseGitUri from 'git-url-parse'; +import { filter, head, get, pipe, reverse } from 'lodash/fp'; + +// NOTE: This can be removed when ES2021 is implemented +import 'core-js/features/promise'; + +const ALLOWED_LOCATION_TYPES = [ + 'azure/api', + 'bitbucket/api', + 'github', + 'github/api', + 'gitlab', + 'gitlab/api', +]; + +export class CodeOwnersProcessor implements LocationProcessor { + async processEntity( + entity: Entity, + location: LocationSpec, + _emit: LocationProcessorEmit, + read: LocationProcessorRead, + ): Promise { + // Only continue if the owner is not set + if ( + !entity || + !['Component', 'API'].includes(entity.kind) || + !ALLOWED_LOCATION_TYPES.includes(location.type) || + (entity.spec && entity.spec.owner) + ) { + return entity; + } + + const owner = await resolveCodeOwner(location, read); + + return { + ...entity, + spec: { ...entity.spec, owner }, + }; + } +} + +export async function resolveCodeOwner( + location: LocationSpec, + read: LocationProcessorRead, +): Promise { + const ownersText = await findRawCodeOwners(location, read); + + if (!ownersText) { + throw Error(`Unable to find codeowners file for: ${location.target}`); + } + + const owners = parseCodeOwners(ownersText); + + return findPrimaryCodeOwner(owners); +} + +export async function findRawCodeOwners( + location: LocationSpec, + read: LocationProcessorRead, +): Promise { + const readOwnerLocation = async (basePath: string): Promise => { + const ownerLocation = buildCodeOwnerLocation( + location, + `${basePath}/CODEOWNERS`, + ); + + const data = await read(ownerLocation); + return data.toString(); + }; + + const gitProvider = location.type.split('/')[0]; + + return Promise.any([ + readOwnerLocation(`/.${gitProvider}`), + readOwnerLocation(''), + readOwnerLocation('/docs'), + ]); +} + +export function parseCodeOwners(ownersText: string) { + return codeowners.parse(ownersText); +} + +export function findPrimaryCodeOwner( + owners: CodeOwnersEntry[], +): string | undefined { + return pipe( + filter((e: CodeOwnersEntry) => e.pattern === '*'), + reverse, + head, + get('owners'), + head, + normalizeCodeOwner, + )(owners); +} + +export function normalizeCodeOwner(owner: string) { + if (owner.match(/^@.*\/.*/)) { + return owner.split('/')[1]; + } else if (owner.match(/^.*@.*\..*$/)) { + return owner.split('@')[0]; + } + + return owner; +} + +export function buildCodeOwnerLocation( + location: LocationSpec, + codeOwnersPath: string, +): LocationSpec { + const { type, target } = location; + + return { type, target: buildUrl({ ...parseGitUri(target), codeOwnersPath }) }; +} + +export function buildUrl({ + protocol = 'https', + source = 'github.com', + owner, + name, + ref = 'master', + codeOwnersPath = '/CODEOWNERS', +}: { + protocol?: string; + source?: string; + owner: string; + name: string; + ref?: string; + codeOwnersPath?: string; +}) { + switch (source) { + case 'dev.azure.com': + case 'azure.com': + throw Error('Azure codeowner url builder not implemented'); + default: + return `${protocol}://${source}/${owner}/${name}/blob/${ref}${codeOwnersPath}`; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/EntityPolicyProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/EntityPolicyProcessor.ts index 7360a01a84..67da9a6705 100644 --- a/plugins/catalog-backend/src/ingestion/processors/EntityPolicyProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/EntityPolicyProcessor.ts @@ -25,6 +25,10 @@ export class EntityPolicyProcessor implements LocationProcessor { } async processEntity(entity: Entity): Promise { - return await this.policy.enforce(entity); + const output = await this.policy.enforce(entity); + if (!output) { + throw new Error(`Entity did not match any known schema`); + } + return output; } } diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts new file mode 100644 index 0000000000..fdba32ef7e --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { LocationSpec } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import { + GithubOrgReaderProcessor, + parseUrl, + readConfig, +} from './GithubOrgReaderProcessor'; + +describe('GithubOrgReaderProcessor', () => { + describe('readConfig', () => { + function config( + providers: { target: string; apiBaseUrl?: string; token?: string }[], + ) { + return ConfigReader.fromConfigs([ + { + context: '', + data: { + catalog: { processors: { githubOrg: { providers } } }, + }, + }, + ]); + } + + it('adds a default GitHub entry when missing', () => { + const output = readConfig(config([])); + expect(output).toEqual([ + { + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }, + ]); + }); + + it('injects the correct GitHub API base URL when missing', () => { + const output = readConfig(config([{ target: 'https://github.com' }])); + expect(output).toEqual([ + { + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }, + ]); + }); + + it('rejects custom targets with no base URLs', () => { + expect(() => + readConfig(config([{ target: 'https://ghe.company.com' }])), + ).toThrow( + 'Provider at https://ghe.company.com must configure an explicit apiBaseUrl', + ); + }); + + it('rejects funky configs', () => { + expect(() => readConfig(config([{ target: 7 } as any]))).toThrow( + /target/, + ); + expect(() => readConfig(config([{ noTarget: '7' } as any]))).toThrow( + /target/, + ); + expect(() => + readConfig( + config([{ target: 'https://github.com', apiBaseUrl: 7 } as any]), + ), + ).toThrow(/apiBaseUrl/); + expect(() => + readConfig(config([{ target: 'https://github.com', token: 7 } as any])), + ).toThrow(/token/); + }); + }); + + describe('parseUrl', () => { + it('only supports clean org urls, and decodes them', () => { + expect(() => parseUrl('https://github.com')).toThrow(); + expect(() => parseUrl('https://github.com/org/foo')).toThrow(); + expect(() => parseUrl('https://github.com/org/foo/teams')).toThrow(); + expect(parseUrl('https://github.com/foo%32')).toEqual({ org: 'foo2' }); + }); + }); + + describe('implementation', () => { + it('rejects unknown types', async () => { + const processor = new GithubOrgReaderProcessor({ + providers: [ + { + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }, + ], + logger: getVoidLogger(), + }); + const location: LocationSpec = { + type: 'not-github-org', + target: 'https://github.com', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).resolves.toBeFalsy(); + }); + + it('rejects unknown targets', async () => { + const processor = new GithubOrgReaderProcessor({ + providers: [ + { + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }, + ], + logger: getVoidLogger(), + }); + const location: LocationSpec = { + type: 'github-org', + target: 'https://not.github.com/apa', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).rejects.toThrow( + /There is no GitHub Org provider that matches https:\/\/not.github.com\/apa/, + ); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts new file mode 100644 index 0000000000..b8a6f6c43f --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts @@ -0,0 +1,191 @@ +/* + * 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 { LocationSpec } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { graphql } from '@octokit/graphql'; +import { Logger } from 'winston'; +import * as results from './results'; +import { LocationProcessor, LocationProcessorEmit } from './types'; +import { getOrganizationTeams, getOrganizationUsers } from './util/github'; +import { buildOrgHierarchy } from './util/org'; + +/** + * Extracts teams and users out of a GitHub org. + */ +export class GithubOrgReaderProcessor implements LocationProcessor { + private readonly providers: ProviderConfig[]; + private readonly logger: Logger; + + static fromConfig(config: Config, options: { logger: Logger }) { + return new GithubOrgReaderProcessor({ + ...options, + providers: readConfig(config), + }); + } + + constructor(options: { providers: ProviderConfig[]; logger: Logger }) { + this.providers = options.providers; + this.logger = options.logger; + } + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: LocationProcessorEmit, + ): Promise { + if (location.type !== 'github-org') { + return false; + } + + const provider = this.providers.find(p => + location.target.startsWith(`${p.target}/`), + ); + if (!provider) { + throw new Error( + `There is no GitHub Org provider that matches ${location.target}. Please add a configuration entry for it under catalog.processors.githubOrg.providers.`, + ); + } + + const { org } = parseUrl(location.target); + const client = !provider.token + ? graphql + : graphql.defaults({ + headers: { + authorization: `token ${provider.token}`, + }, + }); + + // Read out all of the raw data + const startTimestamp = Date.now(); + this.logger.info('Reading GitHub users and groups'); + + const { users } = await getOrganizationUsers(client, org); + const { groups, groupMemberUsers } = await getOrganizationTeams( + client, + org, + ); + + const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1); + this.logger.debug( + `Read ${users.length} GitHub users and ${groups.length} GitHub groups in ${duration} seconds`, + ); + + // Fill out the hierarchy + const usersByName = new Map(users.map(u => [u.metadata.name, u])); + for (const [groupName, userNames] of groupMemberUsers.entries()) { + for (const userName of userNames) { + const user = usersByName.get(userName); + if (user && !user.spec.memberOf.includes(groupName)) { + user.spec.memberOf.push(groupName); + } + } + } + buildOrgHierarchy(groups); + + // Done! + for (const group of groups) { + emit(results.entity(location, group)); + } + for (const user of users) { + emit(results.entity(location, user)); + } + + return true; + } +} + +/* + * Helpers + */ + +/** + * The configuration parameters for a single GitHub API provider. + */ +type ProviderConfig = { + /** + * The prefix of the target that this matches on, e.g. "https://github.com", + * with no trailing slash. + */ + target: 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. + */ + apiBaseUrl?: string; + + /** + * The authorization token to use for requests to this provider. + * + * If no token is specified, anonymous access is used. + */ + token?: string; +}; + +// TODO(freben): Break out common code and config from here and GithubReaderProcessor +export function readConfig(config: Config): ProviderConfig[] { + const providers: ProviderConfig[] = []; + + const providerConfigs = + config.getOptionalConfigArray('catalog.processors.githubOrg.providers') ?? + []; + + // First read all the explicit providers + for (const providerConfig of providerConfigs) { + const target = providerConfig.getString('target').replace(/\/+$/, ''); + let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl'); + const token = providerConfig.getOptionalString('token'); + + if (apiBaseUrl) { + apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + } else if (target === 'https://github.com') { + apiBaseUrl = 'https://api.github.com'; + } + + if (!apiBaseUrl) { + throw new Error( + `Provider at ${target} must configure an explicit apiBaseUrl`, + ); + } + + providers.push({ target, apiBaseUrl, token }); + } + + // If no explicit github.com provider was added, put one in the list as + // a convenience + if (!providers.some(p => p.target === 'https://github.com')) { + providers.push({ + target: 'https://github.com', + apiBaseUrl: 'https://api.github.com', + }); + } + + return providers; +} + +export function parseUrl(urlString: string): { org: string } { + const path = new URL(urlString).pathname.substr(1).split('/'); + + // /spotify + if (path.length === 1 && path[0].length) { + return { org: decodeURIComponent(path[0]) }; + } + + throw new Error(`Expected a URL pointing to /`); +} diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts index 4f0c66148f..89d1f69860 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts @@ -22,6 +22,11 @@ import { Logger } from 'winston'; import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; +// *********************************************************************** +// * NOTE: This has been replaced by packages/backend-common/src/reading * +// * Don't implement new functionality here as this file will be removed * +// *********************************************************************** + /** * The configuration parameters for a single GitHub API provider. */ @@ -99,8 +104,7 @@ export function getApiUrl(target: string, provider: ProviderConfig): URL { !owner || !name || !ref || - (filepathtype !== 'blob' && filepathtype !== 'raw') || - !filepath?.match(/\.ya?ml$/) + (filepathtype !== 'blob' && filepathtype !== 'raw') ) { throw new Error('Wrong URL or invalid file path'); } @@ -125,8 +129,7 @@ export function getRawUrl(target: string, provider: ProviderConfig): URL { !owner || !name || !ref || - (filepathtype !== 'blob' && filepathtype !== 'raw') || - !filepath?.match(/\.ya?ml$/) + (filepathtype !== 'blob' && filepathtype !== 'raw') ) { throw new Error('Wrong URL or invalid file path'); } diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts deleted file mode 100644 index 48ed270049..0000000000 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts +++ /dev/null @@ -1,134 +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 { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor'; -import { ConfigReader } from '@backstage/config'; - -describe('GitlabApiReaderProcessor', () => { - const createConfig = (token: string | undefined) => - ConfigReader.fromConfigs([ - { - context: '', - data: { - catalog: { - processors: { - gitlabApi: { - privateToken: token, - }, - }, - }, - }, - }, - ]); - - it('should build raw api', () => { - const processor = new GitlabApiReaderProcessor(createConfig(undefined)); - - const tests = [ - { - target: - 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', - url: new URL( - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', - ), - err: undefined, - }, - { - target: - 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', - url: new URL( - 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', - ), - err: undefined, - }, - { - target: - 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup - url: new URL( - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', - ), - err: undefined, - }, - { - target: - 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/', - url: null, - err: - 'Incorrect url: https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/, Error: GitLab url does not end in .ya?ml', - }, - ]; - - for (const test of tests) { - if (test.err) { - expect(() => processor.buildRawUrl(test.target, 12345)).toThrowError( - test.err, - ); - } else if (test.url) { - expect(processor.buildRawUrl(test.target, 12345).toString()).toEqual( - test.url.toString(), - ); - } else { - throw new Error( - 'This should not have happened. Either err or url should have matched.', - ); - } - } - }); - - it('should return request options', () => { - const tests = [ - { - token: '0123456789', - expect: { - headers: { - 'PRIVATE-TOKEN': '0123456789', - }, - }, - }, - { - token: '', - err: - "Invalid type in config for key 'catalog.processors.gitlabApi.privateToken' in '', got empty-string, wanted string", - expect: { - headers: { - 'PRIVATE-TOKEN': '', - }, - }, - }, - { - token: undefined, - expect: { - headers: { - 'PRIVATE-TOKEN': '', - }, - }, - }, - ]; - - for (const test of tests) { - if (test.err) { - expect( - () => new GitlabApiReaderProcessor(createConfig(test.token)), - ).toThrowError(test.err); - } else { - const processor = new GitlabApiReaderProcessor( - createConfig(test.token), - ); - expect(processor.getRequestOptions()).toEqual(test.expect); - } - } - }); -}); diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts index 067edba3d9..aeba7a7ebe 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts @@ -20,6 +20,11 @@ import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; import { Config } from '@backstage/config'; +// *********************************************************************** +// * NOTE: This has been replaced by packages/backend-common/src/reading * +// * Don't implement new functionality here as this file will be removed * +// *********************************************************************** + export class GitlabApiReaderProcessor implements LocationProcessor { private privateToken: string; @@ -83,10 +88,6 @@ export class GitlabApiReaderProcessor implements LocationProcessor { const branchAndfilePath = url.pathname.split('/-/blob/')[1]; - if (!branchAndfilePath.match(/\.ya?ml$/)) { - throw new Error('GitLab url does not end in .ya?ml'); - } - const [branch, ...filePath] = branchAndfilePath.split('/'); url.pathname = [ diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts index 9f308ee184..b51bf85a8e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts @@ -19,6 +19,11 @@ import fetch from 'node-fetch'; import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; +// *********************************************************************** +// * NOTE: This has been replaced by packages/backend-common/src/reading * +// * Don't implement new functionality here as this file will be removed * +// *********************************************************************** + export class GitlabReaderProcessor implements LocationProcessor { async readLocation( location: LocationSpec, @@ -62,19 +67,15 @@ export class GitlabReaderProcessor implements LocationProcessor { try { const url = new URL(target); - const [ - empty, - userOrOrg, - repoName, - blobKeyword, - ...restOfPath - ] = url.pathname.split('/'); + const [empty, userOrOrg, repoName, , ...restOfPath] = url.pathname + .split('/') + // for the common case https://gitlab.example.com/a/b/-/blob/master/c.yaml + .filter(path => path !== '-'); if ( empty !== '' || userOrOrg === '' || repoName === '' || - blobKeyword !== 'blob' || !restOfPath.join('/').match(/\.yaml$/) ) { throw new Error('Wrong GitLab URL'); diff --git a/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts new file mode 100644 index 0000000000..a32c96480f --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.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 { LocationSpec } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { Logger } from 'winston'; +import { + LdapClient, + LdapProviderConfig, + readLdapConfig, + readLdapOrg, +} from './ldap'; +import * as results from './results'; +import { LocationProcessor, LocationProcessorEmit } from './types'; + +/** + * Extracts teams and users out of an LDAP server. + */ +export class LdapOrgReaderProcessor implements LocationProcessor { + private readonly providers: LdapProviderConfig[]; + private readonly logger: Logger; + + static fromConfig(config: Config, options: { logger: Logger }) { + const c = config.getOptionalConfig('catalog.processors.ldapOrg'); + return new LdapOrgReaderProcessor({ + ...options, + providers: c ? readLdapConfig(c) : [], + }); + } + + constructor(options: { providers: LdapProviderConfig[]; logger: Logger }) { + this.providers = options.providers; + this.logger = options.logger; + } + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: LocationProcessorEmit, + ): Promise { + if (location.type !== 'ldap-org') { + return false; + } + + const provider = this.providers.find(p => location.target === p.target); + if (!provider) { + throw new Error( + `There is no LDAP Org provider that matches ${location.target}. Please add a configuration entry for it under catalog.processors.ldapOrg.providers.`, + ); + } + + // Read out all of the raw data + const startTimestamp = Date.now(); + this.logger.info('Reading LDAP users and groups'); + + // Be lazy and create the client each time; even though it's pretty + // inefficient, we usually only do this once per entire refresh loop and + // don't have to worry about timeouts and reconnects etc. + const client = await LdapClient.create(provider.target, provider.bind); + const { users, groups } = await readLdapOrg( + client, + provider.users, + provider.groups, + ); + + const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1); + this.logger.debug( + `Read ${users.length} LDAP users and ${groups.length} LDAP groups in ${duration} seconds`, + ); + + // Done! + for (const group of groups) { + emit(results.entity(location, group)); + } + for (const user of users) { + emit(results.entity(location, user)); + } + + return true; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts new file mode 100644 index 0000000000..6893cae0e7 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts @@ -0,0 +1,421 @@ +/* + * 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 { + jsonPlaceholderResolver, + PlaceholderProcessor, + PlaceholderResolver, + ResolverParams, + yamlPlaceholderResolver, +} from './PlaceholderProcessor'; +import { LocationProcessorEmit, LocationProcessorRead } from './types'; + +describe('PlaceholderProcessor', () => { + const emit: LocationProcessorEmit = jest.fn(); + const read: jest.MockedFunction = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns placeholder-free data unchanged', async () => { + const input: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + }; + const processor = new PlaceholderProcessor({ + foo: async () => 'replaced', + }); + await expect( + processor.processEntity(input, { type: 't', target: 'l' }, emit, read), + ).resolves.toBe(input); + }); + + it('replaces placeholders deep in the data', async () => { + const upperResolver: PlaceholderResolver = jest.fn(async ({ value }) => + value!.toString().toUpperCase(), + ); + const processor = new PlaceholderProcessor({ + upper: upperResolver, + }); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { a: [{ b: { $upper: 'text' } }] }, + }, + { type: 'fake', target: 'http://example.com' }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { a: [{ b: 'TEXT' }] }, + }); + + expect(emit).not.toBeCalled(); + expect(read).not.toBeCalled(); + expect(upperResolver).toBeCalledWith({ + key: 'upper', + value: 'text', + location: { type: 'fake', target: 'http://example.com' }, + read, + }); + }); + + it('rejects multiple placeholders', async () => { + const processor = new PlaceholderProcessor({ + foo: jest.fn(), + bar: jest.fn(), + }); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a', $bar: 'b' } }, + }, + { type: 'a', target: 'b' }, + emit, + read, + ), + ).rejects.toThrow( + 'Placeholders have to be on the form of a single $-prefixed key in an object', + ); + + expect(emit).not.toBeCalled(); + expect(read).not.toBeCalled(); + }); + + it('rejects unknown placeholders', async () => { + const processor = new PlaceholderProcessor({ + bar: jest.fn(), + }); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n', x: { $foo: 'a' } }, + }, + { type: 'a', target: 'b' }, + emit, + read, + ), + ).rejects.toThrow('Encountered unknown placeholder $foo'); + + expect(emit).not.toBeCalled(); + expect(read).not.toBeCalled(); + }); + + it('has builtin text support', async () => { + read.mockResolvedValue(Buffer.from('TEXT', 'utf-8')); + const processor = PlaceholderProcessor.default(); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { $text: '../file.txt' } }, + }, + { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: 'TEXT' }, + }); + + expect(emit).not.toBeCalled(); + expect(read).toBeCalledWith({ + type: 'github', + target: 'https://github.com/spotify/backstage/a/file.txt', + }); + }); + + it('has builtin json support', async () => { + read.mockResolvedValue( + Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + ); + const processor = PlaceholderProcessor.default(); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { $json: './file.json' } }, + }, + { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { a: ['b', 7] } }, + }); + + expect(emit).not.toBeCalled(); + expect(read).toBeCalledWith({ + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/file.json', + }); + }); + + it('has builtin yaml support', async () => { + read.mockResolvedValue(Buffer.from('foo:\n - bar: 7', 'utf-8')); + const processor = PlaceholderProcessor.default(); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { $yaml: '../file.yaml' } }, + }, + { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: { foo: [{ bar: 7 }] } }, + }); + + expect(emit).not.toBeCalled(); + expect(read).toBeCalledWith({ + type: 'github', + target: 'https://github.com/spotify/backstage/a/file.yaml', + }); + }); + + it('resolves absolute path for absolute location', async () => { + read.mockResolvedValue(Buffer.from('TEXT', 'utf-8')); + const processor = PlaceholderProcessor.default(); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { + data: { + $text: 'https://github.com/spotify/backstage/catalog-info.yaml', + }, + }, + }, + { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: 'TEXT' }, + }); + + expect(emit).not.toBeCalled(); + expect(read).toBeCalledWith({ + type: 'github', + target: 'https://github.com/spotify/backstage/catalog-info.yaml', + }); + }); + + it('resolves absolute path for relative file location', async () => { + read.mockResolvedValue(Buffer.from('TEXT', 'utf-8')); + const processor = PlaceholderProcessor.default(); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { + data: { + $text: 'https://github.com/spotify/backstage/catalog-info.yaml', + }, + }, + }, + { + type: 'github', + target: './a/b/catalog-info.yaml', + }, + emit, + read, + ), + ).resolves.toEqual({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { data: 'TEXT' }, + }); + + expect(emit).not.toBeCalled(); + expect(read).toBeCalledWith({ + type: 'github', + target: 'https://github.com/spotify/backstage/catalog-info.yaml', + }); + }); + + it('not resolves relative file path for relative file location', async () => { + // We explicitly don't support this case, as it would allow for file system + // traversel attacks. If we want to implement this, we need to have additional + // security measures in place! + read.mockResolvedValue(Buffer.from('TEXT', 'utf-8')); + const processor = PlaceholderProcessor.default(); + + await expect( + processor.processEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { + data: { + $text: '../c/catalog-info.yaml', + }, + }, + }, + { + type: 'github', + target: './a/b/catalog-info.yaml', + }, + emit, + read, + ), + ).rejects.toThrow( + 'Placeholder $text could not form an URL out of ./a/b/catalog-info.yaml and ../c/catalog-info.yaml', + ); + + expect(emit).not.toBeCalled(); + expect(read).not.toBeCalled(); + }); +}); + +describe('yamlPlaceholderResolver', () => { + const read: jest.MockedFunction = jest.fn(); + const params: ResolverParams = { + key: 'a', + value: './file.yaml', + location: { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + read, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('parses valid yaml', async () => { + read.mockResolvedValue(Buffer.from('foo:\n - bar: 7', 'utf-8')); + await expect(yamlPlaceholderResolver(params)).resolves.toEqual({ + foo: [{ bar: 7 }], + }); + }); + + it('rejects invalid yaml', async () => { + read.mockResolvedValue(Buffer.from('a: 1\n----\n', 'utf-8')); + await expect(yamlPlaceholderResolver(params)).rejects.toThrow( + 'Placeholder $a found an error in the data at ./file.yaml, YAMLSemanticError: Implicit map keys need to be followed by map values', + ); + }); + + it('rejects multi-document yaml', async () => { + read.mockResolvedValue(Buffer.from('foo: 1\n---\nbar: 2\n', 'utf-8')); + await expect(yamlPlaceholderResolver(params)).rejects.toThrow( + 'Placeholder $a expected to find exactly one document of data at ./file.yaml, found 2', + ); + }); + + it('parses valid json', async () => { + read.mockResolvedValue( + Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + ); + await expect(yamlPlaceholderResolver(params)).resolves.toEqual({ + a: ['b', 7], + }); + }); +}); + +describe('jsonPlaceholderResolver', () => { + const read: jest.MockedFunction = jest.fn(); + const params: ResolverParams = { + key: 'a', + value: './file.json', + location: { + type: 'github', + target: 'https://github.com/spotify/backstage/a/b/catalog-info.yaml', + }, + read, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('parses valid json', async () => { + read.mockResolvedValue( + Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + ); + await expect(jsonPlaceholderResolver(params)).resolves.toEqual({ + a: ['b', 7], + }); + }); + + it('rejects invalid json', async () => { + read.mockResolvedValue(Buffer.from('}', 'utf-8')); + await expect(jsonPlaceholderResolver(params)).rejects.toThrow( + 'Placeholder $a failed to parse JSON data at ./file.json, SyntaxError: Unexpected token } in JSON at position 0', + ); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts new file mode 100644 index 0000000000..b811d217d5 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts @@ -0,0 +1,220 @@ +/* + * 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, LocationSpec } from '@backstage/catalog-model'; +import { JsonValue } from '@backstage/config'; +import yaml from 'yaml'; +import { + LocationProcessor, + LocationProcessorEmit, + LocationProcessorRead, +} from './types'; + +export type ResolverParams = { + key: string; + value: JsonValue; + location: LocationSpec; + read: LocationProcessorRead; +}; + +export type PlaceholderResolver = ( + params: ResolverParams, +) => Promise; + +/** + * Traverses raw entity JSON looking for occurrences of $-prefixed placeholders + * that it then fills in with actual data. + */ +export class PlaceholderProcessor implements LocationProcessor { + static default() { + return new PlaceholderProcessor({ + json: jsonPlaceholderResolver, + yaml: yamlPlaceholderResolver, + text: textPlaceholderResolver, + }); + } + + constructor( + private readonly resolvers: Record, + ) {} + + async processEntity( + entity: Entity, + location: LocationSpec, + _emit: LocationProcessorEmit, + read: LocationProcessorRead, + ): Promise { + const process = async (data: any): Promise<[any, boolean]> => { + if (!data || !(data instanceof Object)) { + // Scalars can't have placeholders + return [data, false]; + } + + if (Array.isArray(data)) { + // We're an array - process all entries recursively + const items = await Promise.all(data.map(item => process(item))); + return items.every(([, changed]) => !changed) + ? [data, false] + : [items.map(([item]) => item), true]; + } + + const keys = Object.keys(data); + if (!keys.some(k => k.startsWith('$'))) { + // We're an object but no placeholders at this level - process all + // entries recursively + const entries = await Promise.all( + Object.entries(data).map(([k, v]) => + process(v).then(vp => [k, vp] as const), + ), + ); + return entries.every(([, [, changed]]) => !changed) + ? [data, false] + : [Object.fromEntries(entries.map(([k, [v]]) => [k, v])), true]; + } else if (keys.length !== 1) { + throw new Error( + 'Placeholders have to be on the form of a single $-prefixed key in an object', + ); + } + + const resolverKey = keys[0].substr(1); + const resolver = this.resolvers[resolverKey]; + if (!resolver) { + throw new Error(`Encountered unknown placeholder \$${resolverKey}`); + } + + return [ + await resolver({ + key: resolverKey, + value: data[keys[0]], + location, + read, + }), + true, + ]; + }; + + const [result] = await process(entity); + return result; + } +} + +/* + * Resolvers + */ + +export async function yamlPlaceholderResolver( + params: ResolverParams, +): Promise { + const text = await readTextLocation(params); + + let documents: yaml.Document.Parsed[]; + try { + documents = yaml.parseAllDocuments(text).filter(d => d); + } catch (e) { + throw new Error( + `Placeholder \$${params.key} failed to parse YAML data at ${params.value}, ${e}`, + ); + } + + if (documents.length !== 1) { + throw new Error( + `Placeholder \$${params.key} expected to find exactly one document of data at ${params.value}, found ${documents.length}`, + ); + } + + const document = documents[0]; + + if (document.errors?.length) { + throw new Error( + `Placeholder \$${params.key} found an error in the data at ${params.value}, ${document.errors[0]}`, + ); + } + + return document.toJSON(); +} + +export async function jsonPlaceholderResolver( + params: ResolverParams, +): Promise { + const text = await readTextLocation(params); + + try { + return JSON.parse(text); + } catch (e) { + throw new Error( + `Placeholder \$${params.key} failed to parse JSON data at ${params.value}, ${e}`, + ); + } +} + +export async function textPlaceholderResolver( + params: ResolverParams, +): Promise { + return await readTextLocation(params); +} + +/* + * Helpers + */ + +async function readTextLocation(params: ResolverParams): Promise { + const newLocation = relativeLocation(params); + + try { + const data = await params.read(newLocation); + return data.toString('utf-8'); + } catch (e) { + throw new Error( + `Placeholder \$${params.key} could not read location ${params.value}, ${e}`, + ); + } +} + +function relativeLocation({ + key, + value, + location, +}: ResolverParams): LocationSpec { + if (typeof value !== 'string') { + throw new Error( + `Placeholder \$${key} expected a string value parameter, in the form of an absolute URL or a relative path`, + ); + } + + let url: URL; + try { + // The two-value form of the URL constructor handles relative paths for us + url = new URL(value, location.target); + } catch { + try { + // Check whether value is a valid absolute URL on it's own, if not fail. + url = new URL(value); + } catch { + // The only remaining case that isn't support is a relative file path that should be + // resolved using a relative file location. Accessing local file paths can lead to + // path traversal attacks and access to any file on the host system. Implementing this + // would require additional security measures. + throw new Error( + `Placeholder \$${key} could not form an URL out of ${location.target} and ${value}`, + ); + } + } + + return { + type: location.type, + target: url.toString(), + }; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts index e30b320bf4..009872cda3 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -22,17 +22,21 @@ import { } from './types'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; +import { getVoidLogger, UrlReaders } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; describe('UrlReaderProcessor', () => { const mockApiOrigin = 'http://localhost:23000'; const server = setupServer(); - beforeAll(() => server.listen()); + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); it('should load from url', async () => { - const processor = new UrlReaderProcessor(); + const logger = getVoidLogger(); + const reader = UrlReaders.default({ logger, config: new ConfigReader({}) }); + const processor = new UrlReaderProcessor({ reader, logger }); const spec = { type: 'url', target: `${mockApiOrigin}/component.yaml`, @@ -54,7 +58,9 @@ describe('UrlReaderProcessor', () => { }); it('should fail load from url with error', async () => { - const processor = new UrlReaderProcessor(); + const logger = getVoidLogger(); + const reader = UrlReaders.default({ logger, config: new ConfigReader({}) }); + const processor = new UrlReaderProcessor({ reader, logger }); const spec = { type: 'url', target: `${mockApiOrigin}/component-notfound.yaml`, @@ -74,7 +80,7 @@ describe('UrlReaderProcessor', () => { expect(generated.location).toBe(spec); expect(generated.error.name).toBe('NotFoundError'); expect(generated.error.message).toBe( - `${mockApiOrigin}/component-notfound.yaml could not be read, 404 Not Found`, + `Unable to read url, NotFoundError: could not read ${mockApiOrigin}/component-notfound.yaml, 404 Not Found`, ); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts index 0c879ea70c..2bc05bf1cb 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts @@ -14,40 +14,58 @@ * limitations under the License. */ +import { Logger } from 'winston'; +import { UrlReader } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; -import fetch from 'node-fetch'; import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; +// TODO(Rugvip): Added for backwards compatibility when moving to UrlReader, this +// can be removed in a bit +const deprecatedTypes = [ + 'github', + 'github/api', + 'bitbucket/api', + 'gitlab/api', + 'azure/api', +]; + +type Options = { + reader: UrlReader; + logger: Logger; +}; + export class UrlReaderProcessor implements LocationProcessor { + constructor(private readonly options: Options) {} + async readLocation( location: LocationSpec, optional: boolean, emit: LocationProcessorEmit, ): Promise { - if (location.type !== 'url') { + if (deprecatedTypes.includes(location.type)) { + // TODO(Rugvip): Let's not enable this warning yet, as we want to move over the example YAMLs + // in this repo to use the 'url' type first. + // this.options.logger.warn( + // `Using deprecated location type '${location.type}' for '${location.target}', use 'url' instead`, + // ); + } else if (location.type !== 'url') { return false; } try { - const response = await fetch(location.target); + const data = await this.options.reader.read(location.target); + emit(result.data(location, data)); + } catch (error) { + const message = `Unable to read ${location.type}, ${error}`; - if (response.ok) { - const data = await response.buffer(); - emit(result.data(location, data)); - } else { - const message = `${location.target} could not be read, ${response.status} ${response.statusText}`; - if (response.status === 404) { - if (!optional) { - emit(result.notFoundError(location, message)); - } - } else { - emit(result.generalError(location, message)); + if (error.name === 'NotFoundError') { + if (!optional) { + emit(result.notFoundError(location, message)); } + } else { + emit(result.generalError(location, message)); } - } catch (e) { - const message = `Unable to read ${location.type} ${location.target}, ${e}`; - emit(result.generalError(location, message)); } return true; diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index ed5dc3bf8e..0ce7f11a5f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -18,3 +18,20 @@ import * as results from './results'; export { results }; export * from './types'; + +export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; +export { ApiDefinitionAtLocationProcessor } from './ApiDefinitionAtLocationProcessor'; +export { AzureApiReaderProcessor } from './AzureApiReaderProcessor'; +export { BitbucketApiReaderProcessor } from './BitbucketApiReaderProcessor'; +export { CodeOwnersProcessor } from './CodeOwnersProcessor'; +export { EntityPolicyProcessor } from './EntityPolicyProcessor'; +export { FileReaderProcessor } from './FileReaderProcessor'; +export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; +export { GithubReaderProcessor } from './GithubReaderProcessor'; +export { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor'; +export { GitlabReaderProcessor } from './GitlabReaderProcessor'; +export { LocationRefProcessor } from './LocationEntityProcessor'; +export { PlaceholderProcessor } from './PlaceholderProcessor'; +export { StaticLocationProcessor } from './StaticLocationProcessor'; +export { UrlReaderProcessor } from './UrlReaderProcessor'; +export { YamlProcessor } from './YamlProcessor'; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts new file mode 100644 index 0000000000..1ef2af5f2f --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/client.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 ldap, { Client, SearchEntry, SearchOptions } from 'ldapjs'; +import { BindConfig } from './config'; +import { errorString } from './util'; + +/** + * Basic wrapper for the ldapjs library. + * + * Helps out with promisifying calls, paging, binding etc. + */ +export class LdapClient { + static async create(target: string, bind?: BindConfig): Promise { + const client = ldap.createClient({ url: target }); + if (!bind) { + return new LdapClient(client); + } + + return new Promise((resolve, reject) => { + const { dn, secret } = bind; + client.bind(dn, secret, err => { + if (err) { + reject(`LDAP bind failed for ${dn}, ${errorString(err)}`); + } else { + resolve(new LdapClient(client)); + } + }); + }); + } + + constructor(private readonly client: Client) {} + + /** + * Performs an LDAP search operation. + * + * @param dn The fully qualified base DN to search within + * @param options The search options + */ + async search(dn: string, options: SearchOptions): Promise { + try { + return await new Promise((resolve, reject) => { + const output: SearchEntry[] = []; + + this.client.search(dn, options, (err, res) => { + if (err) { + reject(new Error(errorString(err))); + return; + } + + res.on('searchReference', () => { + reject(new Error('Unable to handle referral')); + }); + + res.on('searchEntry', entry => { + output.push(entry); + }); + + res.on('error', e => { + reject(new Error(errorString(e))); + }); + + res.on('end', r => { + if (!r) { + reject(new Error('Null response')); + } else if (r.status !== 0) { + reject(new Error(`Got status ${r.status}: ${r.errorMessage}`)); + } else { + resolve(output); + } + }); + }); + }); + } catch (e) { + throw new Error(`LDAP search at ${dn} failed, ${e.message}`); + } + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts new file mode 100644 index 0000000000..7e6036668f --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts @@ -0,0 +1,172 @@ +/* + * 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 { readLdapConfig } from './config'; + +describe('readLdapConfig', () => { + it('applies all of the defaults', () => { + const config = { + providers: [ + { + target: 'target', + users: { + dn: 'udn', + }, + groups: { + dn: 'gdn', + }, + }, + ], + }; + const actual = readLdapConfig( + ConfigReader.fromConfigs([{ context: '', data: config }]), + ); + const expected = [ + { + target: 'target', + bind: undefined, + users: { + dn: 'udn', + options: { + scope: 'one', + attributes: ['*', '+'], + }, + set: undefined, + map: { + rdn: 'uid', + name: 'uid', + displayName: 'cn', + email: 'mail', + memberOf: 'memberOf', + }, + }, + groups: { + dn: 'gdn', + options: { + scope: 'one', + attributes: ['*', '+'], + }, + set: undefined, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + type: 'groupType', + memberOf: 'memberOf', + members: 'member', + }, + }, + }, + ]; + expect(actual).toEqual(expected); + }); + + it('reads all the values', () => { + const config = { + providers: [ + { + target: 'target', + bind: { dn: 'bdn', secret: 's' }, + users: { + dn: 'udn', + options: { + scope: 'base', + attributes: ['*'], + filter: 'f', + paged: true, + }, + set: [{ path: 'p', value: 'v' }], + map: { + rdn: 'u', + name: 'v', + description: 'd', + displayName: 'c', + email: 'm', + picture: 'p', + memberOf: 'm', + }, + }, + groups: { + dn: 'gdn', + options: { + scope: 'base', + attributes: ['*'], + filter: 'f', + paged: true, + }, + set: [{ path: 'p', value: 'v' }], + map: { + rdn: 'u', + name: 'v', + description: 'd', + type: 't', + memberOf: 'm', + members: 'n', + }, + }, + }, + ], + }; + const actual = readLdapConfig( + ConfigReader.fromConfigs([{ context: '', data: config }]), + ); + const expected = [ + { + target: 'target', + bind: { dn: 'bdn', secret: 's' }, + users: { + dn: 'udn', + options: { + scope: 'base', + attributes: ['*'], + filter: 'f', + paged: true, + }, + set: [{ path: 'p', value: 'v' }], + map: { + rdn: 'u', + name: 'v', + description: 'd', + displayName: 'c', + email: 'm', + picture: 'p', + memberOf: 'm', + }, + }, + groups: { + dn: 'gdn', + options: { + scope: 'base', + attributes: ['*'], + filter: 'f', + paged: true, + }, + set: [{ path: 'p', value: 'v' }], + map: { + rdn: 'u', + name: 'v', + description: 'd', + type: 't', + memberOf: 'm', + members: 'n', + }, + }, + }, + ]; + expect(actual).toEqual(expected); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts new file mode 100644 index 0000000000..74c02e3f84 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts @@ -0,0 +1,264 @@ +/* + * 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, JsonValue } from '@backstage/config'; +import { SearchOptions } from 'ldapjs'; +import mergeWith from 'lodash/mergeWith'; +import { RecursivePartial } from './util'; + +/** + * The configuration parameters for a single LDAP provider. + */ +export type LdapProviderConfig = { + // The prefix of the target that this matches on, e.g. + // "ldaps://ds.example.net", with no trailing slash. + target: string; + // The settings to use for the bind command. If none are specified, the bind + // command is not issued. + bind?: BindConfig; + // The settings that govern the reading and interpretation of users + users: UserConfig; + // The settings that govern the reading and interpretation of groups + groups: GroupConfig; +}; + +/** + * The settings to use for the a command. + */ +export type BindConfig = { + // The DN of the user to auth as, e.g. + // uid=ldap-robot,ou=robots,ou=example,dc=example,dc=net + dn: string; + // The secret of the user to auth as (its password) + secret: string; +}; + +/** + * The settings that govern the reading and interpretation of users. + */ +export type UserConfig = { + // The DN under which users are stored. + dn: string; + // The search options to use. + // Only the scope, filter, attributes, and paged fields are supported. The + // default is scope "one" and attributes "*" and "+". + options: SearchOptions; + // JSON paths (on a.b.c form) and hard coded values to set on those paths + set?: { path: string; value: JsonValue }[]; + // Mappings from well known entity fields, to LDAP attribute names + map: { + // The name of the attribute that holds the relative distinguished name of + // each entry. Defaults to "uid". + rdn: string; + // The name of the attribute that shall be used for the value of the + // metadata.name field of the entity. Defaults to "uid". + name: string; + // The name of the attribute that shall be used for the value of the + // metadata.description field of the entity. + description?: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.displayName field of the entity. Defaults to "cn". + displayName: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.email field of the entity. Defaults to "mail". + email: string; + // The name of the attribute that shall be used for the value of the + // spec.profile.picture field of the entity. + picture?: string; + // The name of the attribute that shall be used for the values of the + // spec.memberOf field of the entity. Defaults to "memberOf". + memberOf: string; + }; +}; + +/** + * The settings that govern the reading and interpretation of groups. + */ +export type GroupConfig = { + // The DN under which groups are stored. + dn: string; + // The search options to use. + // Only the scope, filter, attributes, and paged fields are supported. + options: SearchOptions; + // JSON paths (on a.b.c form) and hard coded values to set on those paths + set?: { path: string; value: JsonValue }[]; + // Mappings from well known entity fields, to LDAP attribute names + map: { + // The name of the attribute that holds the relative distinguished name of + // each entry. Defaults to "cn". + rdn: string; + // The name of the attribute that shall be used for the value of the + // metadata.name field of the entity. Defaults to "cn". + name: string; + // The name of the attribute that shall be used for the value of the + // metadata.description field of the entity. Defaults to "description". + description: string; + // The name of the attribute that shall be used for the value of the + // spec.type field of the entity. Defaults to "groupType". + type: string; + // The name of the attribute that shall be used for the values of the + // spec.parent field of the entity. Defaults to "memberOf". + memberOf: string; + // The name of the attribute that shall be used for the values of the + // spec.children field of the entity. Defaults to "member". + members: string; + }; +}; + +const defaultConfig = { + users: { + options: { + scope: 'one', + attributes: ['*', '+'], + }, + map: { + rdn: 'uid', + name: 'uid', + displayName: 'cn', + email: 'mail', + memberOf: 'memberOf', + }, + }, + groups: { + options: { + scope: 'one', + attributes: ['*', '+'], + }, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + type: 'groupType', + memberOf: 'memberOf', + members: 'member', + }, + }, +}; + +/** + * Parses configuration. + * + * @param config The root of the LDAP config hierarchy + */ +export function readLdapConfig(config: Config): LdapProviderConfig[] { + function readBindConfig( + c: Config | undefined, + ): LdapProviderConfig['bind'] | undefined { + if (!c) { + return undefined; + } + return { + dn: c.getString('dn'), + secret: c.getString('secret'), + }; + } + + function readOptionsConfig(c: Config | undefined): SearchOptions { + if (!c) { + return {}; + } + return { + scope: c.getOptionalString('scope') as SearchOptions['scope'], + filter: c.getOptionalString('filter'), + attributes: c.getOptionalStringArray('attributes'), + paged: c.getOptionalBoolean('paged'), + }; + } + + function readSetConfig( + c: Config[] | undefined, + ): { path: string; value: JsonValue }[] | undefined { + if (!c) { + return undefined; + } + return c.map(entry => ({ + path: entry.getString('path'), + value: entry.get('value'), + })); + } + + function readUserMapConfig( + c: Config | undefined, + ): Partial { + if (!c) { + return {}; + } + + return { + rdn: c.getOptionalString('rdn'), + name: c.getOptionalString('name'), + description: c.getOptionalString('description'), + displayName: c.getOptionalString('displayName'), + email: c.getOptionalString('email'), + picture: c.getOptionalString('picture'), + memberOf: c.getOptionalString('memberOf'), + }; + } + + function readGroupMapConfig( + c: Config | undefined, + ): Partial { + if (!c) { + return {}; + } + + return { + rdn: c.getOptionalString('rdn'), + name: c.getOptionalString('name'), + description: c.getOptionalString('description'), + type: c.getOptionalString('type'), + memberOf: c.getOptionalString('memberOf'), + members: c.getOptionalString('members'), + }; + } + + function readUserConfig( + c: Config, + ): RecursivePartial { + return { + dn: c.getString('dn'), + options: readOptionsConfig(c.getOptionalConfig('options')), + set: readSetConfig(c.getOptionalConfigArray('set')), + map: readUserMapConfig(c.getOptionalConfig('map')), + }; + } + + function readGroupConfig( + c: Config, + ): RecursivePartial { + return { + dn: c.getString('dn'), + options: readOptionsConfig(c.getOptionalConfig('options')), + set: readSetConfig(c.getOptionalConfigArray('set')), + map: readGroupMapConfig(c.getOptionalConfig('map')), + }; + } + + const providerConfigs = config.getOptionalConfigArray('providers') ?? []; + return providerConfigs.map(c => { + const newConfig = { + target: c.getString('target').replace(/\/+$/, ''), + bind: readBindConfig(c.getOptionalConfig('bind')), + users: readUserConfig(c.getConfig('users')), + groups: readGroupConfig(c.getConfig('groups')), + }; + const merged = mergeWith({}, defaultConfig, newConfig, (_into, from) => { + // Replace arrays instead of merging, otherwise default behavior + return Array.isArray(from) ? from : undefined; + }); + return merged as LdapProviderConfig; + }); +} diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/constants.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/constants.ts new file mode 100644 index 0000000000..3f32a34da4 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/constants.ts @@ -0,0 +1,48 @@ +/* + * 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. + */ + +/** + * The name of an entity annotation, that references the RDN of the LDAP object + * it was ingested from. + * + * The RDN is the name of the leftmost attribute that identifies the item; for + * example, for an item with the fully qualified DN + * uid=john,ou=people,ou=spotify,dc=spotify,dc=net the generated entity would + * have this attribute, with the value "john". + */ +export const LDAP_RDN_ANNOTATION = 'backstage.io/ldap-rdn'; + +/** + * The name of an entity annotation, that references the DN of the LDAP object + * it was ingested from. + * + * The DN is the fully qualified name that identifies the item; for example, + * for an item with the DN uid=john,ou=people,ou=spotify,dc=spotify,dc=net the + * generated entity would have this attribute, with that full string as its + * value. + */ +export const LDAP_DN_ANNOTATION = 'backstage.io/ldap-dn'; + +/** + * The name of an entity annotation, that references the UUID of the LDAP + * object it was ingested from. + * + * The UUID is the globally unique ID that identifies the item; for example, + * for an item with the UUID 76ef928a-b251-1037-9840-d78227f36a7e, the + * generated entity would have this attribute, with that full string as its + * value. + */ +export const LDAP_UUID_ANNOTATION = 'backstage.io/ldap-uuid'; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/index.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/index.ts new file mode 100644 index 0000000000..2f8dae7486 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/index.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. + */ + +export { LdapClient } from './client'; +export { readLdapConfig } from './config'; +export type { LdapProviderConfig } from './config'; +export { + LDAP_DN_ANNOTATION, + LDAP_RDN_ANNOTATION, + LDAP_UUID_ANNOTATION, +} from './constants'; +export { readLdapOrg } from './read'; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts new file mode 100644 index 0000000000..e7a7d1c567 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts @@ -0,0 +1,393 @@ +/* + * 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 { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { SearchEntry } from 'ldapjs'; +import merge from 'lodash/merge'; +import { LdapClient } from './client'; +import { GroupConfig, UserConfig } from './config'; +import { + LDAP_DN_ANNOTATION, + LDAP_RDN_ANNOTATION, + LDAP_UUID_ANNOTATION, +} from './constants'; +import { readLdapGroups, readLdapUsers, resolveRelations } from './read'; +import { RecursivePartial } from './util'; + +function user(data: RecursivePartial): UserEntity { + return merge( + {}, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'name' }, + spec: { profile: {}, memberOf: [] }, + } as UserEntity, + data, + ); +} + +function group(data: RecursivePartial): GroupEntity { + return merge( + {}, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'name' }, + spec: { type: 'type', ancestors: [], children: [], descendants: [] }, + } as GroupEntity, + data, + ); +} + +function searchEntry(attributes: Record): SearchEntry { + return { + attributes: Object.entries(attributes).map(([k, vs]) => ({ + json: { + type: k, + vals: vs, + }, + })), + } as any; +} + +describe('readLdapUsers', () => { + const client: jest.Mocked = { + search: jest.fn(), + } as any; + + afterEach(() => jest.resetAllMocks()); + + it('transfers all attributes', async () => { + client.search.mockResolvedValue([ + searchEntry({ + uid: ['uid-value'], + description: ['description-value'], + cn: ['cn-value'], + mail: ['mail-value'], + avatarUrl: ['avatarUrl-value'], + memberOf: ['x', 'y', 'z'], + entryDN: ['dn-value'], + entryUUID: ['uuid-value'], + }), + ]); + const config: UserConfig = { + dn: 'ddd', + options: {}, + map: { + rdn: 'uid', + name: 'uid', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + memberOf: 'memberOf', + }, + }; + const { users, userMemberOf } = await readLdapUsers(client, config); + expect(users).toEqual([ + expect.objectContaining({ + metadata: { + name: 'uid-value', + description: 'description-value', + annotations: { + [LDAP_DN_ANNOTATION]: 'dn-value', + [LDAP_RDN_ANNOTATION]: 'uid-value', + [LDAP_UUID_ANNOTATION]: 'uuid-value', + }, + }, + spec: { + profile: { + displayName: 'cn-value', + email: 'mail-value', + picture: 'avatarUrl-value', + }, + memberOf: [], + }, + }), + ]); + expect(userMemberOf).toEqual( + new Map([['dn-value', new Set(['x', 'y', 'z'])]]), + ); + }); +}); + +describe('readLdapGroups', () => { + const client: jest.Mocked = { + search: jest.fn(), + } as any; + + afterEach(() => jest.resetAllMocks()); + + it('transfers all attributes', async () => { + client.search.mockResolvedValue([ + searchEntry({ + cn: ['cn-value'], + description: ['description-value'], + tt: ['type-value'], + memberOf: ['x', 'y', 'z'], + member: ['e', 'f', 'g'], + entryDN: ['dn-value'], + entryUUID: ['uuid-value'], + }), + ]); + const config: GroupConfig = { + dn: 'ddd', + options: {}, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + type: 'tt', + memberOf: 'memberOf', + members: 'member', + }, + }; + const { groups, groupMember, groupMemberOf } = await readLdapGroups( + client, + config, + ); + expect(groups).toEqual([ + expect.objectContaining({ + metadata: { + name: 'cn-value', + description: 'description-value', + annotations: { + [LDAP_DN_ANNOTATION]: 'dn-value', + [LDAP_RDN_ANNOTATION]: 'cn-value', + [LDAP_UUID_ANNOTATION]: 'uuid-value', + }, + }, + spec: { + type: 'type-value', + ancestors: [], + children: [], + descendants: [], + }, + }), + ]); + expect(groupMember).toEqual( + new Map([['dn-value', new Set(['e', 'f', 'g'])]]), + ); + expect(groupMemberOf).toEqual( + new Map([['dn-value', new Set(['x', 'y', 'z'])]]), + ); + }); +}); + +describe('resolveRelations', () => { + describe('lookup', () => { + it('matches by DN', () => { + const parent = group({ + metadata: { + name: 'parent', + annotations: { [LDAP_DN_ANNOTATION]: 'pa' }, + }, + }); + const child = group({ + metadata: { + name: 'child', + annotations: { [LDAP_DN_ANNOTATION]: 'ca' }, + }, + }); + const groupMember = new Map>([ + ['pa', new Set(['ca'])], + ]); + resolveRelations([parent, child], [], new Map(), new Map(), groupMember); + expect(parent.spec.children).toEqual(['child']); + expect(child.spec.parent).toEqual('parent'); + }); + it('matches by UUID', () => { + const parent = group({ + metadata: { + name: 'parent', + annotations: { [LDAP_UUID_ANNOTATION]: 'pa' }, + }, + }); + const child = group({ + metadata: { + name: 'child', + annotations: { [LDAP_UUID_ANNOTATION]: 'ca' }, + }, + }); + const groupMember = new Map>([ + ['pa', new Set(['ca'])], + ]); + resolveRelations([parent, child], [], new Map(), new Map(), groupMember); + expect(parent.spec.children).toEqual(['child']); + expect(child.spec.parent).toEqual('parent'); + }); + }); + + describe('userMemberOf', () => { + it('populates relations by dn', () => { + const host = group({ + metadata: { name: 'host', annotations: { [LDAP_DN_ANNOTATION]: 'ha' } }, + }); + const member = user({ + metadata: { + name: 'member', + annotations: { [LDAP_DN_ANNOTATION]: 'ma' }, + }, + }); + const userMemberOf = new Map>([ + ['ma', new Set(['ha'])], + ]); + resolveRelations([host], [member], userMemberOf, new Map(), new Map()); + expect(member.spec.memberOf).toEqual(['host']); + }); + + it('populates relations by uuid', () => { + const host = group({ + metadata: { + name: 'host', + annotations: { [LDAP_UUID_ANNOTATION]: 'ha' }, + }, + }); + const member = user({ + metadata: { + name: 'member', + annotations: { [LDAP_DN_ANNOTATION]: 'ma' }, + }, + }); + const userMemberOf = new Map>([ + ['ma', new Set(['ha'])], + ]); + resolveRelations([host], [member], userMemberOf, new Map(), new Map()); + expect(member.spec.memberOf).toEqual(['host']); + }); + }); + + describe('groupMemberOf', () => { + it('populates relations by dn', () => { + const parent = group({ + metadata: { + name: 'parent', + annotations: { [LDAP_DN_ANNOTATION]: 'pa' }, + }, + }); + const child = group({ + metadata: { + name: 'child', + annotations: { [LDAP_DN_ANNOTATION]: 'ca' }, + }, + }); + const groupMemberOf = new Map>([ + ['ca', new Set(['pa'])], + ]); + resolveRelations( + [parent, child], + [], + new Map(), + groupMemberOf, + new Map(), + ); + expect(parent.spec.children).toEqual(['child']); + expect(child.spec.parent).toEqual('parent'); + }); + }); + + it('populates relations by uuid', () => { + const parent = group({ + metadata: { + name: 'parent', + annotations: { [LDAP_UUID_ANNOTATION]: 'pa' }, + }, + }); + const child = group({ + metadata: { + name: 'child', + annotations: { [LDAP_UUID_ANNOTATION]: 'ca' }, + }, + }); + const groupMemberOf = new Map>([ + ['ca', new Set(['pa'])], + ]); + resolveRelations([parent, child], [], new Map(), groupMemberOf, new Map()); + expect(parent.spec.children).toEqual(['child']); + expect(child.spec.parent).toEqual('parent'); + }); + + describe('groupMember', () => { + it('populates relations by dn', () => { + const parent = group({ + metadata: { + name: 'parent', + annotations: { [LDAP_DN_ANNOTATION]: 'pa' }, + }, + }); + const child = group({ + metadata: { + name: 'child', + annotations: { [LDAP_DN_ANNOTATION]: 'ca' }, + }, + }); + const member = user({ + metadata: { + name: 'member', + annotations: { [LDAP_DN_ANNOTATION]: 'ma' }, + }, + }); + const groupMember = new Map>([ + ['pa', new Set(['ca', 'ma'])], + ]); + resolveRelations( + [parent, child], + [member], + new Map(), + new Map(), + groupMember, + ); + expect(parent.spec.children).toEqual(['child']); + expect(child.spec.parent).toEqual('parent'); + expect(member.spec.memberOf).toEqual(['parent']); + }); + + it('populates relations by uuid', () => { + const parent = group({ + metadata: { + name: 'parent', + annotations: { [LDAP_UUID_ANNOTATION]: 'pa' }, + }, + }); + const child = group({ + metadata: { + name: 'child', + annotations: { [LDAP_UUID_ANNOTATION]: 'ca' }, + }, + }); + const member = user({ + metadata: { + name: 'member', + annotations: { [LDAP_UUID_ANNOTATION]: 'ma' }, + }, + }); + const groupMember = new Map>([ + ['pa', new Set(['ca', 'ma'])], + ]); + resolveRelations( + [parent, child], + [member], + new Map(), + new Map(), + groupMember, + ); + expect(parent.spec.children).toEqual(['child']); + expect(child.spec.parent).toEqual('parent'); + expect(member.spec.memberOf).toEqual(['parent']); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts new file mode 100644 index 0000000000..63fce00bd8 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts @@ -0,0 +1,422 @@ +/* + * 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 { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import lodashSet from 'lodash/set'; +import { buildOrgHierarchy } from '../util/org'; +import { LdapClient } from './client'; +import { GroupConfig, UserConfig } from './config'; +import { + LDAP_DN_ANNOTATION, + LDAP_RDN_ANNOTATION, + LDAP_UUID_ANNOTATION, +} from './constants'; + +// NOTE(freben): These attribute names are assumed to be the same across all +// compliant LDAP implementations +const DN_ATTRIBUTE = 'entryDN'; +const UUID_ATTRIBUTE = 'entryUUID'; + +/** + * Reads groups out of an LDAP provider. + * + * @param client The LDAP client + * @param config The user data configuration + */ +export async function readLdapUsers( + client: LdapClient, + config: UserConfig, +): Promise<{ + users: UserEntity[]; // With all relations empty + userMemberOf: Map>; // DN -> DN or UUID of groups +}> { + const { dn, options, set, map } = config; + + const entries = await client.search(dn, options); + + const entities: UserEntity[] = []; + const userMemberOf: Map> = new Map(); + + for (const entry of entries) { + const attributes = new Map( + entry.attributes.map(attr => { + const data = attr.json; + return [data.type, data.vals]; + }), + ); + + const entity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: '', + annotations: {}, + }, + spec: { + profile: {}, + memberOf: [], + }, + }; + + if (set) { + for (const { path, value } of set) { + lodashSet(entity, path, value); + } + } + + mapStringAttr(attributes, map.name, v => { + entity.metadata.name = v; + }); + mapStringAttr(attributes, map.description, v => { + entity.metadata.description = v; + }); + mapStringAttr(attributes, map.rdn, v => { + entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; + }); + mapStringAttr(attributes, UUID_ATTRIBUTE, v => { + entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; + }); + mapStringAttr(attributes, DN_ATTRIBUTE, v => { + entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; + }); + mapStringAttr(attributes, map.displayName, v => { + entity.spec.profile!.displayName = v; + }); + mapStringAttr(attributes, map.email, v => { + entity.spec.profile!.email = v; + }); + mapStringAttr(attributes, map.picture, v => { + entity.spec.profile!.picture = v; + }); + + mapReferencesAttr(attributes, map.memberOf, (myDn, vs) => { + ensureItems(userMemberOf, myDn, vs); + }); + + entities.push(entity); + } + + return { users: entities, userMemberOf }; +} + +/** + * Reads groups out of an LDAP provider. + * + * @param client The LDAP client + * @param config The group data configuration + */ +export async function readLdapGroups( + client: LdapClient, + config: GroupConfig, +): Promise<{ + groups: GroupEntity[]; // With all relations empty + groupMemberOf: Map>; // DN -> DN or UUID of groups + groupMember: Map>; // DN -> DN or UUID of groups & users +}> { + const { dn, options, set, map } = config; + const entries = await client.search(dn, options); + + const groups: GroupEntity[] = []; + const groupMemberOf: Map> = new Map(); + const groupMember: Map> = new Map(); + + for (const entry of entries) { + const attributes = new Map( + entry.attributes.map(attr => { + const data = attr.json; + return [data.type, data.vals]; + }), + ); + + const entity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: '', + annotations: {}, + }, + spec: { + type: 'unknown', + ancestors: [], + children: [], + descendants: [], + }, + }; + + if (set) { + for (const { path, value } of set) { + lodashSet(entity, path, value); + } + } + + mapStringAttr(attributes, map.name, v => { + entity.metadata.name = v; + }); + mapStringAttr(attributes, map.description, v => { + entity.metadata.description = v; + }); + mapStringAttr(attributes, map.rdn, v => { + entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v; + }); + mapStringAttr(attributes, UUID_ATTRIBUTE, v => { + entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v; + }); + mapStringAttr(attributes, DN_ATTRIBUTE, v => { + entity.metadata.annotations![LDAP_DN_ANNOTATION] = v; + }); + mapStringAttr(attributes, map.type, v => { + entity.spec.type = v; + }); + + mapReferencesAttr(attributes, map.memberOf, (myDn, vs) => { + ensureItems(groupMemberOf, myDn, vs); + }); + mapReferencesAttr(attributes, map.members, (myDn, vs) => { + ensureItems(groupMember, myDn, vs); + }); + + groups.push(entity); + } + + return { + groups, + groupMemberOf, + groupMember, + }; +} + +/** + * Reads users and groups out of an LDAP provider. + * + * Invokes the above "raw" read functions and stitches together the results + * with all relations etc filled in. + * + * @param client The LDAP client + * @param logger A logger instance + * @param userConfig The user data configuration + * @param groupConfig The group data configuration + */ +export async function readLdapOrg( + client: LdapClient, + userConfig: UserConfig, + groupConfig: GroupConfig, +): Promise<{ + users: UserEntity[]; + groups: GroupEntity[]; +}> { + const { users, userMemberOf } = await readLdapUsers(client, userConfig); + const { groups, groupMemberOf, groupMember } = await readLdapGroups( + client, + groupConfig, + ); + + resolveRelations(groups, users, userMemberOf, groupMemberOf, groupMember); + users.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + groups.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + + return { users, groups }; +} + +// +// Helpers +// + +// Maps a single-valued attribute to a consumer +function mapStringAttr( + attributes: Map, + attributeName: string | undefined, + setter: (value: string) => void, +) { + if (attributeName) { + const values = attributes.get(attributeName); + if (values && values.length === 1) { + setter(values[0]); + } + } +} + +// Maps a multi-valued attribute of references to other objects, to a consumer +function mapReferencesAttr( + attributes: Map, + attributeName: string | undefined, + setter: (sourceDn: string, targets: string[]) => void, +) { + if (attributeName) { + const values = attributes.get(attributeName); + const dn = attributes.get(DN_ATTRIBUTE); + if (values && dn && dn.length === 1) { + setter(dn[0], values); + } + } +} + +// Inserts a number of values in a key-values mapping +function ensureItems( + target: Map>, + key: string, + values: string[], +) { + if (key) { + let set = target.get(key); + if (!set) { + set = new Set(); + target.set(key, set); + } + for (const value of values) { + if (value) { + set!.add(value); + } + } + } +} + +/** + * Takes groups and entities with empty relations, and fills in the various + * relations that were returned by the readers, and forms the org hierarchy. + * + * @param groups Group entities with empty relations; modified in place + * @param users User entities with empty relations; modified in place + * @param userMemberOf For a user DN, the set of group DNs or UUIDs that the + * user is a member of + * @param groupMemberOf For a group DN, the set of group DNs or UUIDs that the + * group is a member of (parents in the hierarchy) + * @param groupMember For a group DN, the set of group DNs or UUIDs that are + * members of the group (children in the hierarchy) + */ +export function resolveRelations( + groups: GroupEntity[], + users: UserEntity[], + userMemberOf: Map>, + groupMemberOf: Map>, + groupMember: Map>, +) { + // Build reference lookup tables - all of the relations that are output from + // the above calls can be expressed as either DNs or UUIDs so we need to be + // able to find by both, as well as the name. Note that we expect them to not + // collide here - this is a reasonable assumption as long as the fields are + // the supported forms. + const userMap: Map = new Map(); // by name, dn, uuid + const groupMap: Map = new Map(); // by name, dn, uuid + for (const user of users) { + userMap.set(user.metadata.name, user); + userMap.set(user.metadata.annotations![LDAP_DN_ANNOTATION], user); + userMap.set(user.metadata.annotations![LDAP_UUID_ANNOTATION], user); + } + for (const group of groups) { + groupMap.set(group.metadata.name, group); + groupMap.set(group.metadata.annotations![LDAP_DN_ANNOTATION], group); + groupMap.set(group.metadata.annotations![LDAP_UUID_ANNOTATION], group); + } + + // This can happen e.g. if entryUUID wasn't returned by the server + userMap.delete(''); + groupMap.delete(''); + userMap.delete(undefined!); + groupMap.delete(undefined!); + + // Fill in all of the immediate relations, now keyed on metadata.name. We + // keep all parents at this point, whether the target model can support more + // than one or not (it gets filtered farther down). And group children are + // only groups in here. + const newUserMemberOf: Map> = new Map(); + const newGroupParents: Map> = new Map(); + const newGroupChildren: Map> = new Map(); + + // Resolve and store in the intermediaries. It may seem redundant that the + // input data has both parent and children directions, as well as both + // user->group and group->user - the reason is that different LDAP schemas + // express relations in different directions. Some may have a user memberOf + // overlay, some don't, for example. + for (const [userN, groupsN] of userMemberOf.entries()) { + const user = userMap.get(userN); + if (user) { + for (const groupN of groupsN) { + const group = groupMap.get(groupN); + if (group) { + ensureItems(newUserMemberOf, user.metadata.name, [ + group.metadata.name, + ]); + } + } + } + } + for (const [groupN, parentsN] of groupMemberOf.entries()) { + const group = groupMap.get(groupN); + if (group) { + for (const parentN of parentsN) { + const parentGroup = groupMap.get(parentN); + if (parentGroup) { + ensureItems(newGroupParents, group.metadata.name, [ + parentGroup.metadata.name, + ]); + ensureItems(newGroupChildren, parentGroup.metadata.name, [ + group.metadata.name, + ]); + } + } + } + } + for (const [groupN, membersN] of groupMember.entries()) { + const group = groupMap.get(groupN); + if (group) { + for (const memberN of membersN) { + // Group members can be both users and groups in the input model, so + // try both + const memberUser = userMap.get(memberN); + if (memberUser) { + ensureItems(newUserMemberOf, memberUser.metadata.name, [ + group.metadata.name, + ]); + } else { + const memberGroup = groupMap.get(memberN); + if (memberGroup) { + ensureItems(newGroupChildren, group.metadata.name, [ + memberGroup.metadata.name, + ]); + ensureItems(newGroupParents, memberGroup.metadata.name, [ + group.metadata.name, + ]); + } + } + } + } + } + + // Write down the relations again into the actual entities + for (const [userN, groupsN] of newUserMemberOf.entries()) { + const user = userMap.get(userN); + if (user) { + user.spec.memberOf = Array.from(groupsN).sort(); + } + } + for (const [groupN, parentsN] of newGroupParents.entries()) { + if (parentsN.size === 1) { + const group = groupMap.get(groupN); + if (group) { + group.spec.parent = parentsN.values().next().value; + } + } + } + for (const [groupN, childrenN] of newGroupChildren.entries()) { + const group = groupMap.get(groupN); + if (group) { + group.spec.children = Array.from(childrenN).sort(); + } + } + + // Fill out the rest of the hierarchy + buildOrgHierarchy(groups); +} diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/util.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/util.test.ts new file mode 100644 index 0000000000..2def6ad960 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/util.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 { errorString, RecursivePartial } from './util'; + +describe('errorString', () => { + it('formats', () => { + const e = { code: 1, name: 'n', message: 'm' }; + expect(errorString(e)).toEqual('1 n: m'); + }); +}); + +describe('RecursivePartial', () => { + it('is recursive', () => { + type X = { + required: { + required: string; + }; + }; + const x: RecursivePartial = { + required: {}, + }; + expect(x).toEqual({ required: {} }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/util.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/util.ts new file mode 100644 index 0000000000..29159dc219 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/util.ts @@ -0,0 +1,37 @@ +/* + * 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 { Error as LDAPError } from 'ldapjs'; + +/** + * Builds a string form of an LDAP Error structure. + * + * @param error The error + */ +export function errorString(error: LDAPError) { + return `${error.code} ${error.name}: ${error.message}`; +} + +/** + * Makes all keys of an entire hierarchy optional. + */ +export type RecursivePartial = { + [P in keyof T]?: T[P] extends (infer U)[] + ? RecursivePartial[] + : T[P] extends object + ? RecursivePartial + : T[P]; +}; diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index c8f3f6f482..66359b328b 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -50,13 +50,15 @@ export type LocationProcessor = { * * @param entity The entity to process * @param location The location that the entity came from + * @param read Reads the contents of a location * @param emit A sink for auxiliary items resulting from the processing - * @returns The same entity or a modifid version of it + * @returns The same entity or a modified version of it */ processEntity?( entity: Entity, location: LocationSpec, emit: LocationProcessorEmit, + read: LocationProcessorRead, ): Promise; /** @@ -64,7 +66,7 @@ export type LocationProcessor = { * * @param error The error * @param location The location where the error occurred - * @param emit A sink for items resulting from this handilng + * @param emit A sink for items resulting from this handling * @returns Nothing */ handleError?( @@ -107,3 +109,5 @@ export type LocationProcessorResult = | LocationProcessorDataResult | LocationProcessorEntityResult | LocationProcessorErrorResult; + +export type LocationProcessorRead = (location: LocationSpec) => Promise; diff --git a/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts new file mode 100644 index 0000000000..25a37e0546 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts @@ -0,0 +1,147 @@ +/* + * 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 { graphql } from '@octokit/graphql'; +import { graphql as graphqlMsw } from 'msw'; +import { setupServer } from 'msw/node'; +import { + getOrganizationTeams, + getOrganizationUsers, + getTeamMembers, + QueryResponse, +} from './github'; + +describe('github', () => { + const server = setupServer(); + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + describe('getOrganizationUsers', () => { + it('reads members', async () => { + const input: QueryResponse = { + organization: { + membersWithRole: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + login: 'a', + name: 'b', + bio: 'c', + email: 'd', + avatarUrl: 'e', + }, + ], + }, + }, + }; + + const output = { + users: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'a', description: 'c' }), + spec: { + profile: { displayName: 'b', email: 'd', picture: 'e' }, + memberOf: [], + }, + }), + ], + }; + + server.use( + graphqlMsw.query('users', (_req, res, ctx) => res(ctx.data(input))), + ); + + await expect(getOrganizationUsers(graphql, 'a')).resolves.toEqual(output); + }); + }); + + describe('getOrganizationTeams', () => { + it('reads teams', async () => { + const input: QueryResponse = { + organization: { + teams: { + pageInfo: { hasNextPage: false }, + nodes: [ + { + slug: 'team', + combinedSlug: 'blah/team', + parentTeam: { + slug: 'parent', + combinedSlug: '', + members: { pageInfo: { hasNextPage: false }, nodes: [] }, + }, + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'user' }], + }, + }, + ], + }, + }, + }; + + const output = { + groups: [ + expect.objectContaining({ + metadata: expect.objectContaining({ name: 'team' }), + spec: { + type: 'team', + parent: 'parent', + ancestors: [], + children: [], + descendants: [], + }, + }), + ], + groupMemberUsers: new Map([['team', ['user']]]), + }; + + server.use( + graphqlMsw.query('teams', (_req, res, ctx) => res(ctx.data(input))), + ); + + await expect(getOrganizationTeams(graphql, 'a')).resolves.toEqual(output); + }); + }); + + describe('getTeamMembers', () => { + it('reads team members', async () => { + const input: QueryResponse = { + organization: { + team: { + slug: '', + combinedSlug: '', + members: { + pageInfo: { hasNextPage: false }, + nodes: [{ login: 'user' }], + }, + }, + }, + }; + + const output = { + members: ['user'], + }; + + server.use( + graphqlMsw.query('members', (_req, res, ctx) => res(ctx.data(input))), + ); + + await expect(getTeamMembers(graphql, 'a', 'b')).resolves.toEqual(output); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/util/github.ts b/plugins/catalog-backend/src/ingestion/processors/util/github.ts new file mode 100644 index 0000000000..96cf4a7668 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/util/github.ts @@ -0,0 +1,297 @@ +/* + * 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 { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { graphql } from '@octokit/graphql'; + +// Graphql types + +export type QueryResponse = { + organization: Organization; +}; + +export type Organization = { + membersWithRole?: Connection; + team?: Team; + teams?: Connection; +}; + +export type PageInfo = { + hasNextPage: boolean; + endCursor?: string; +}; + +export type User = { + login: string; + bio?: string; + avatarUrl?: string; + email?: string; + name?: string; +}; + +export type Team = { + slug: string; + combinedSlug: string; + description?: string; + parentTeam?: Team; + members: Connection; +}; + +export type Connection = { + pageInfo: PageInfo; + nodes: T[]; +}; + +/** + * Gets all the users out of a GitHub organization. + * + * Note that the users will not have their memberships filled in. + * + * @param client An octokit graphql client + * @param org The slug of the org to read + */ +export async function getOrganizationUsers( + client: typeof graphql, + org: string, +): Promise<{ users: UserEntity[] }> { + const query = ` + query users($org: String!, $cursor: String) { + organization(login: $org) { + membersWithRole(first: 100, after: $cursor) { + pageInfo { hasNextPage, endCursor } + nodes { avatarUrl, bio, email, login, name } + } + } + }`; + + // There is no user -> teams edge, so we leave the memberships empty for + // now and let the team iteration handle it instead + const mapper = (user: User) => { + const entity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: user.login, + annotations: { + 'github.com/user-login': user.login, + }, + }, + spec: { + profile: {}, + memberOf: [], + }, + }; + + if (user.bio) entity.metadata.description = user.bio; + if (user.name) entity.spec.profile!.displayName = user.name; + if (user.email) entity.spec.profile!.email = user.email; + if (user.avatarUrl) entity.spec.profile!.picture = user.avatarUrl; + + return entity; + }; + + const users = await queryWithPaging( + client, + query, + r => r.organization?.membersWithRole, + mapper, + { org }, + ); + + return { users }; +} + +/** + * Gets all the teams out of a GitHub organization. + * + * Note that the teams will not have any relations apart from parent filled in. + * + * @param client An octokit graphql client + * @param org The slug of the org to read + */ +export async function getOrganizationTeams( + client: typeof graphql, + org: string, +): Promise<{ + groups: GroupEntity[]; + groupMemberUsers: Map; +}> { + const query = ` + query teams($org: String!, $cursor: String) { + organization(login: $org) { + teams(first: 100, after: $cursor) { + pageInfo { hasNextPage, endCursor } + nodes { + slug + combinedSlug + parentTeam { slug } + members(first: 100, membership: IMMEDIATE) { + pageInfo { hasNextPage } + nodes { login } + } + } + } + } + }`; + + // Gets populated inside the mapper below + const groupMemberUsers = new Map(); + + const mapper = async (team: Team) => { + const entity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: team.slug, + annotations: { + 'github.com/team-slug': team.combinedSlug, + }, + }, + spec: { + type: 'team', + ancestors: [], + children: [], + descendants: [], + }, + }; + + if (team.description) entity.metadata.description = team.description; + if (team.parentTeam) entity.spec.parent = team.parentTeam.slug; + + const memberNames: string[] = []; + groupMemberUsers.set(team.slug, memberNames); + + if (!team.members.pageInfo.hasNextPage) { + // We got all the members in one go, run the fast path + for (const user of team.members.nodes) { + memberNames.push(user.login); + } + } else { + // There were more than a hundred immediate members - run the slow + // path of fetching them explicitly + const { members } = await getTeamMembers(client, org, team.slug); + for (const userLogin of members) { + memberNames.push(userLogin); + } + } + + return entity; + }; + + const groups = await queryWithPaging( + client, + query, + r => r.organization?.teams, + mapper, + { org }, + ); + + return { groups, groupMemberUsers }; +} + +/** + * Gets all the users out of a GitHub organization. + * + * Note that the users will not have their memberships filled in. + * + * @param client An octokit graphql client + * @param org The slug of the org to read + * @param teamSlug The slug of the team to read + */ +export async function getTeamMembers( + client: typeof graphql, + org: string, + teamSlug: string, +): Promise<{ members: string[] }> { + const query = ` + query members($org: String!, $teamSlug: String!, $cursor: String) { + organization(login: $org) { + team(slug: $teamSlug) { + members(first: 100, after: $cursor, membership: IMMEDIATE) { + pageInfo { hasNextPage, endCursor } + nodes { login } + } + } + } + }`; + + const members = await queryWithPaging( + client, + query, + r => r.organization?.team?.members, + user => user.login, + { org, teamSlug }, + ); + + return { members }; +} + +// +// Helpers +// + +/** + * Assists in repeatedly executing a query with a paged response. + * + * Requires that the query accepts a $cursor variable. + * + * @param client The octokit client + * @param query The query to execute + * @param connection A function that, given the response, picks out the actual + * Connection object that's being iterated + * @param mapper A function that, given one of the nodes in the Connection, + * returns the model mapped form of it + * @param variables The variable values that the query needs, minus the cursor + */ +export async function queryWithPaging< + GraphqlType, + OutputType, + Variables extends {}, + Response = QueryResponse +>( + client: typeof graphql, + query: string, + connection: (response: Response) => Connection | undefined, + mapper: (item: GraphqlType) => Promise | OutputType, + variables: Variables, +): Promise { + const result: OutputType[] = []; + + let cursor: string | undefined = undefined; + for (let j = 0; j < 1000 /* just for sanity */; ++j) { + const response: Response = await client(query, { + ...variables, + cursor, + }); + + const conn = connection(response); + if (!conn) { + throw new Error(`Found no match for ${JSON.stringify(variables)}`); + } + + for (const node of conn.nodes) { + result.push(await mapper(node)); + } + + if (!conn.pageInfo.hasNextPage) { + break; + } else { + cursor = conn.pageInfo.endCursor; + } + } + + return result; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts new file mode 100644 index 0000000000..e9dddc8226 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.test.ts @@ -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 { GroupEntity } from '@backstage/catalog-model'; +import { buildOrgHierarchy } from './org'; + +function g( + name: string, + parent: string | undefined, + children: string[], +): GroupEntity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name }, + spec: { type: 'team', parent, children, ancestors: [], descendants: [] }, + }; +} + +describe('buildOrgHierarchy', () => { + it('adds groups to their parent.children', () => { + const a = g('a', undefined, []); + const b = g('b', 'a', []); + const c = g('c', 'b', []); + const d = g('d', 'a', []); + buildOrgHierarchy([a, b, c, d]); + expect(a.spec.children).toEqual(expect.arrayContaining(['b', 'd'])); + expect(b.spec.children).toEqual(expect.arrayContaining(['c'])); + expect(c.spec.children).toEqual([]); + expect(d.spec.children).toEqual([]); + }); + + it('fills out descendants', () => { + const a = g('a', undefined, []); + const b = g('b', 'a', []); + const c = g('c', 'b', []); + const d = g('d', 'a', []); + buildOrgHierarchy([a, b, c, d]); + expect(a.spec.descendants).toEqual(expect.arrayContaining(['b', 'c', 'd'])); + expect(b.spec.descendants).toEqual(expect.arrayContaining(['c'])); + expect(c.spec.descendants).toEqual([]); + expect(d.spec.descendants).toEqual([]); + }); + + it('fills out ancestors', () => { + const a = g('a', undefined, []); + const b = g('b', 'a', []); + const c = g('c', 'b', []); + const d = g('d', 'a', []); + buildOrgHierarchy([a, b, c, d]); + expect(a.spec.ancestors).toEqual([]); + expect(b.spec.ancestors).toEqual(expect.arrayContaining(['a'])); + expect(c.spec.ancestors).toEqual(expect.arrayContaining(['a', 'b'])); + expect(d.spec.ancestors).toEqual(expect.arrayContaining(['a'])); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/util/org.ts b/plugins/catalog-backend/src/ingestion/processors/util/org.ts new file mode 100644 index 0000000000..a280a265a8 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/util/org.ts @@ -0,0 +1,95 @@ +/* + * 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 { GroupEntity } from '@backstage/catalog-model'; + +export function buildOrgHierarchy(groups: GroupEntity[]) { + const groupsByName = new Map(groups.map(g => [g.metadata.name, g])); + + // + // Make sure that g.parent.children contain g + // + + for (const group of groups) { + const selfName = group.metadata.name; + const parentName = group.spec.parent; + if (parentName) { + const parent = groupsByName.get(parentName); + if (parent && !parent.spec.children.includes(selfName)) { + parent.spec.children.push(selfName); + } + } + } + + // + // Make sure that g.descendants is complete + // + + function visitDescendants(current: GroupEntity): string[] { + if (current.spec.descendants.length) { + return current.spec.descendants; + } + + const accumulator = new Set(); + for (const childName of current.spec.children) { + accumulator.add(childName); + const child = groupsByName.get(childName); + if (child) { + for (const d of visitDescendants(child)) { + accumulator.add(d); + } + } + } + + const descendants = Array.from(accumulator); + current.spec.descendants = descendants; + return descendants; + } + + for (const group of groups) { + visitDescendants(group); + } + + // + // Make sure that g.ancestors is complete + // + + function visitAncestors(current: GroupEntity): string[] { + if (current.spec.ancestors.length) { + return current.spec.ancestors; + } + + let ancestors: string[]; + const parentName = current.spec.parent; + if (!parentName) { + ancestors = []; + } else { + const parent = groupsByName.get(parentName); + if (parent) { + ancestors = [parentName, ...visitAncestors(parent)]; + } else { + ancestors = [parentName]; + } + } + + current.spec.ancestors = ancestors; + return ancestors; + } + + for (const group of groups) { + visitAncestors(group); + } +} diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index 29b3f7ce04..32d7b6aa63 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -136,7 +136,11 @@ describe('createRouter', () => { const response = await request(app).get('/entities/by-name/k/ns/n'); expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('k', 'ns', 'n'); + expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({ + kind: 'k', + namespace: 'ns', + name: 'n', + }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); @@ -147,7 +151,11 @@ describe('createRouter', () => { const response = await request(app).get('/entities/by-name/b/d/c'); expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('b', 'd', 'c'); + expect(entitiesCatalog.entityByName).toHaveBeenCalledWith({ + kind: 'b', + namespace: 'd', + name: 'c', + }); expect(response.status).toEqual(404); expect(response.text).toMatch(/name/); }); diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index 2919e73396..8da589c721 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -67,11 +67,11 @@ export async function createRouter( }) .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { const { kind, namespace, name } = req.params; - const entity = await entitiesCatalog.entityByName( + const entity = await entitiesCatalog.entityByName({ kind, namespace, name, - ); + }); if (!entity) { res .status(404) diff --git a/plugins/catalog-backend/src/service/standaloneServer.ts b/plugins/catalog-backend/src/service/standaloneServer.ts index 0b532d1664..5939c7eb58 100644 --- a/plugins/catalog-backend/src/service/standaloneServer.ts +++ b/plugins/catalog-backend/src/service/standaloneServer.ts @@ -17,6 +17,7 @@ import { createServiceBuilder, loadBackendConfig, + UrlReaders, } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { Server } from 'http'; @@ -39,12 +40,13 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'catalog-backend' }); const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const reader = UrlReaders.default({ logger, config }); logger.debug('Creating application...'); const db = await DatabaseManager.createInMemoryDatabase({ logger }); const entitiesCatalog = new DatabaseEntitiesCatalog(db); const locationsCatalog = new DatabaseLocationsCatalog(db); - const locationReader = new LocationReaders({ logger, config }); + const locationReader = new LocationReaders({ logger, config, reader }); const higherOrderOperation = new HigherOrderOperations( entitiesCatalog, locationsCatalog, diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index c508215f29..0c18624e0f 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graphql", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.22", - "@backstage/backend-common": "^0.1.1-alpha.22", - "@backstage/config": "^0.1.1-alpha.22", + "@backstage/backend-common": "^0.1.1-alpha.24", + "@backstage/catalog-model": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", "@graphql-modules/core": "^0.7.17", "apollo-server": "^2.16.1", "graphql": "^15.3.0", @@ -32,16 +32,16 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@types/express": "^4.17.7", - "@types/supertest": "^2.0.8", + "@backstage/cli": "^0.1.1-alpha.24", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", "@graphql-codegen/typescript-resolvers": "^1.17.7", - "ts-node": "^8.10.2", + "@types/express": "^4.17.7", + "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", - "msw": "^0.19.5", - "supertest": "^4.0.2" + "msw": "^0.20.5", + "supertest": "^4.0.2", + "ts-node": "^8.10.2" }, "files": [ "dist" diff --git a/plugins/catalog-graphql/src/graphql/module.test.ts b/plugins/catalog-graphql/src/graphql/module.test.ts index 09708f68f4..e3e03a9b4a 100644 --- a/plugins/catalog-graphql/src/graphql/module.test.ts +++ b/plugins/catalog-graphql/src/graphql/module.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { createModule } from './module'; import { execute } from 'graphql'; import { rest } from 'msw'; @@ -36,9 +37,8 @@ describe('Catalog Module', () => { }, ]); - beforeAll(() => worker.listen()); + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); afterAll(() => worker.close()); - afterEach(() => worker.resetHandlers()); describe('Default Entity', () => { diff --git a/plugins/catalog-graphql/src/graphql/module.ts b/plugins/catalog-graphql/src/graphql/module.ts index 06b2ea3f2b..be5fed11ef 100644 --- a/plugins/catalog-graphql/src/graphql/module.ts +++ b/plugins/catalog-graphql/src/graphql/module.ts @@ -22,6 +22,7 @@ import { Config } from '@backstage/config'; import { CatalogClient } from '../service/client'; import GraphQLJSON, { GraphQLJSONObject } from 'graphql-type-json'; import { Entity } from '@backstage/catalog-model'; +import typeDefs from '../schema'; export interface ModuleOptions { logger: Logger; @@ -31,8 +32,6 @@ export interface ModuleOptions { export async function createModule( options: ModuleOptions, ): Promise { - const { default: typeDefs } = require('../schema'); - const catalogClient = new CatalogClient( options.config.getString('backend.baseUrl'), ); diff --git a/plugins/catalog-graphql/src/service/client.test.ts b/plugins/catalog-graphql/src/service/client.test.ts index 876059802b..d8cc59d1a8 100644 --- a/plugins/catalog-graphql/src/service/client.test.ts +++ b/plugins/catalog-graphql/src/service/client.test.ts @@ -20,9 +20,8 @@ import { setupServer } from 'msw/node'; describe('Catalog GraphQL Module', () => { const worker = setupServer(); - beforeAll(() => worker.listen()); + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); afterAll(() => worker.close()); - afterEach(() => worker.resetHandlers()); const baseUrl = 'http://localhost:1234'; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index f5520f39f9..ea270b6fe9 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "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.22", - "@backstage/core": "^0.1.1-alpha.22", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.22", - "@backstage/plugin-techdocs": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@backstage/catalog-model": "^0.1.1-alpha.24", + "@backstage/core": "^0.1.1-alpha.24", + "@backstage/plugin-scaffolder": "^0.1.1-alpha.24", + "@backstage/plugin-techdocs": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,9 +41,9 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", - "@backstage/test-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/test-utils": "^0.1.1-alpha.24", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", @@ -52,6 +52,7 @@ "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3", "msw": "^0.20.5", + "node-fetch": "^2.6.1", "react-test-renderer": "^16.13.1", "whatwg-fetch": "^3.4.0" }, diff --git a/plugins/catalog/src/api/CatalogClient.test.ts b/plugins/catalog/src/api/CatalogClient.test.ts index 18f062db77..832a114276 100644 --- a/plugins/catalog/src/api/CatalogClient.test.ts +++ b/plugins/catalog/src/api/CatalogClient.test.ts @@ -25,7 +25,7 @@ const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); describe('CatalogClient', () => { - beforeAll(() => server.listen()); + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index a98eec0875..bc23d61d9c 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -58,7 +58,7 @@ describe('Catalog Filter', () => { ] as Entity[]), }; - const indentityApi: Partial = { + const identityApi: Partial = { getUserId: () => 'tools@example.com', }; @@ -68,7 +68,7 @@ describe('Catalog Filter', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index db490cf542..ea99753c14 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -46,6 +46,9 @@ const useStyles = makeStyles(theme => ({ gridTemplateColumns: '250px 1fr', gridColumnGap: theme.spacing(2), }, + buttonSpacing: { + marginLeft: theme.spacing(2), + }, })); const CatalogPageContents = () => { @@ -56,6 +59,7 @@ const CatalogPageContents = () => { reload, matchingEntities, availableTags, + isCatalogEmpty, } = useFilteredEntities(); const configApi = useApi(configApiRef); const catalogApi = useApi(catalogApiRef); @@ -141,6 +145,9 @@ const CatalogPageContents = () => { [isStarredEntity, userId, orgName], ); + const showAddExampleEntities = + configApi.has('catalog.exampleEntityLocations') && isCatalogEmpty; + return ( { > Create Component + {showAddExampleEntities && ( + + )} All your software catalog entities
@@ -174,7 +191,6 @@ const CatalogPageContents = () => { entities={matchingEntities} loading={loading} error={error} - onAddMockData={addMockData} />
diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index d1eef2a005..46d6010f2f 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -46,7 +46,6 @@ describe('CatalogTable component', () => { entities={[]} loading={false} error={{ code: 'error' }} - onAddMockData={() => {}} />, ), ); @@ -63,7 +62,6 @@ describe('CatalogTable component', () => { titlePreamble="Owned" entities={entities} loading={false} - onAddMockData={() => {}} />, ), ); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index cd4df13775..f5885145b3 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -16,11 +16,10 @@ import { Entity, LocationSpec } from '@backstage/catalog-model'; import { Table, TableColumn, TableProps } from '@backstage/core'; import { Chip, Link } from '@material-ui/core'; -import Add from '@material-ui/icons/Add'; import Edit from '@material-ui/icons/Edit'; import GitHub from '@material-ui/icons/GitHub'; import { Alert } from '@material-ui/lab'; -import React, { Dispatch } from 'react'; +import React from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { findLocationForEntityMeta } from '../../data/utils'; import { useStarredEntities } from '../../hooks/useStarredEntites'; @@ -87,7 +86,6 @@ type CatalogTableProps = { titlePreamble: string; loading: boolean; error?: any; - onAddMockData: Dispatch; }; export const CatalogTable = ({ @@ -95,7 +93,6 @@ export const CatalogTable = ({ loading, error, titlePreamble, - onAddMockData, }: CatalogTableProps) => { const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); @@ -151,13 +148,6 @@ export const CatalogTable = ({ onClick: () => toggleStarredEntity(rowData), }; }, - { - icon: () => , - tooltip: 'Add example components', - isFreeAction: true, - onClick: onAddMockData, - hidden: !(entities && entities.length === 0), - }, ]; return ( diff --git a/plugins/catalog/src/components/EntityNotFound/EntityNotFound.tsx b/plugins/catalog/src/components/EntityNotFound/EntityNotFound.tsx new file mode 100644 index 0000000000..c7560c6894 --- /dev/null +++ b/plugins/catalog/src/components/EntityNotFound/EntityNotFound.tsx @@ -0,0 +1,61 @@ +/* + * 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 { Grid, Button, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { BackstageTheme } from '@backstage/theme'; + +import { Illo } from './Illo'; + +const useStyles = makeStyles(theme => ({ + container: { + paddingTop: theme.spacing(24), + paddingLeft: theme.spacing(8), + }, + title: { + paddingBottom: theme.spacing(2), + }, + body: { + paddingBottom: theme.spacing(6), + }, +})); + +export const EntityNotFound = () => { + const classes = useStyles(); + + return ( + + + + + Entity was not found + + + Want to help us build this? Check out our Getting Started + documentation. + + + + + ); +}; diff --git a/plugins/catalog/src/components/EntityNotFound/Illo.jsx b/plugins/catalog/src/components/EntityNotFound/Illo.jsx new file mode 100644 index 0000000000..36b8d84f5d --- /dev/null +++ b/plugins/catalog/src/components/EntityNotFound/Illo.jsx @@ -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 React from 'react'; +import { makeStyles } from '@material-ui/core'; +import IlloSvgUrl from './illo.svg'; + +const useStyles = makeStyles({ + illo: { + maxWidth: '60%', + top: 100, + right: 20, + position: 'absolute', + }, +}); + +export const Illo = () => { + const classes = useStyles(); + return ; +}; diff --git a/plugins/catalog/src/components/EntityNotFound/illo.svg b/plugins/catalog/src/components/EntityNotFound/illo.svg new file mode 100644 index 0000000000..849ec2fed8 --- /dev/null +++ b/plugins/catalog/src/components/EntityNotFound/illo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/catalog/src/components/EntityNotFound/index.ts b/plugins/catalog/src/components/EntityNotFound/index.ts new file mode 100644 index 0000000000..613ea7b36d --- /dev/null +++ b/plugins/catalog/src/components/EntityNotFound/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 { EntityNotFound } from './EntityNotFound'; diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index 61855f56d9..c6e706c15b 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -15,6 +15,7 @@ */ import React, { ComponentType } from 'react'; import { CatalogPage } from './CatalogPage'; +import { EntityNotFound } from './EntityNotFound'; import { EntityPageLayout } from './EntityPageLayout'; import { Route, Routes } from 'react-router'; import { entityRoute, rootRoute } from '../routes'; @@ -45,9 +46,10 @@ const DefaultEntityPage = () => ( ); const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => { - const { entity } = useEntity(); + const { entity, loading, error } = useEntity(); // Loading and error states - if (!entity) return ; + if (loading) return ; + if (error || (!loading && !entity)) return ; // Otherwise EntityPage provided from the App // Note that EntityPage will include EntityPageLayout already diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx index 94f1fe3b70..ceecc17953 100644 --- a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -62,6 +62,7 @@ function useProvideEntityFilters(): FilterGroupsContext { }>({}); const [matchingEntities, setMatchingEntities] = useState([]); const [availableTags, setAvailableTags] = useState([]); + const [isCatalogEmpty, setCatalogEmpty] = useState(false); useEffect(() => { doReload(); @@ -86,6 +87,7 @@ function useProvideEntityFilters(): FilterGroupsContext { ), ); setAvailableTags(collectTags(entities)); + setCatalogEmpty(entities !== undefined && entities.length === 0); }, [entities, error]); const register = useCallback( @@ -143,6 +145,7 @@ function useProvideEntityFilters(): FilterGroupsContext { filterGroupStates, matchingEntities, availableTags, + isCatalogEmpty, }; } diff --git a/plugins/catalog/src/filter/context.ts b/plugins/catalog/src/filter/context.ts index 838ec8d532..c025480fa6 100644 --- a/plugins/catalog/src/filter/context.ts +++ b/plugins/catalog/src/filter/context.ts @@ -33,6 +33,7 @@ export type FilterGroupsContext = { filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; matchingEntities: Entity[]; availableTags: string[]; + isCatalogEmpty: boolean; }; /** diff --git a/plugins/catalog/src/filter/useFilteredEntities.ts b/plugins/catalog/src/filter/useFilteredEntities.ts index 138a7547c0..2d7dcfd89d 100644 --- a/plugins/catalog/src/filter/useFilteredEntities.ts +++ b/plugins/catalog/src/filter/useFilteredEntities.ts @@ -31,6 +31,7 @@ export function useFilteredEntities() { error: context.error, matchingEntities: context.matchingEntities, availableTags: context.availableTags, + isCatalogEmpty: context.isCatalogEmpty, reload: context.reload, }; } diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog/src/hooks/useEntity.ts index 53b1cff73a..77ea8c1569 100644 --- a/plugins/catalog/src/hooks/useEntity.ts +++ b/plugins/catalog/src/hooks/useEntity.ts @@ -20,8 +20,6 @@ import { catalogApiRef } from '../api/types'; import { useAsync } from 'react-use'; import { Entity } from '@backstage/catalog-model'; -const REDIRECT_DELAY = 2000; - type EntityLoadingStatus = { entity?: Entity; loading: boolean; @@ -47,13 +45,6 @@ export const useEntityFromUrl = (): EntityLoadingStatus => { ); useEffect(() => { - if (error || (!loading && !entity)) { - errorApi.post(new Error('Entity not found!')); - setTimeout(() => { - navigate('/'); - }, REDIRECT_DELAY); - } - if (!name) { errorApi.post(new Error('No name provided!')); navigate('/'); @@ -67,6 +58,10 @@ export const useEntityFromUrl = (): EntityLoadingStatus => { * Always going to return an entity, or throw an error if not a descendant of a EntityProvider. */ export const useEntity = () => { - const { entity } = useContext<{ entity: Entity }>(EntityContext as any); - return { entity }; + const { entity, loading, error } = useContext<{ + entity: Entity; + loading: boolean; + error: Error; + }>(EntityContext as any); + return { entity, loading, error }; }; diff --git a/plugins/circleci/README.md b/plugins/circleci/README.md index 48e1bee6cc..d329768514 100644 --- a/plugins/circleci/README.md +++ b/plugins/circleci/README.md @@ -55,13 +55,9 @@ import { Router as CircleCIRouter } from '@backstage/plugin-circleci'; proxy: '/circleci/api': target: https://circleci.com/api/v1.1 - changeOrigin: true - pathRewrite: - '^/proxy/circleci/api/': '/' headers: Circle-Token: - $secret: - env: CIRCLECI_AUTH_TOKEN + $env: CIRCLECI_AUTH_TOKEN ``` 5. Get and provide `CIRCLECI_AUTH_TOKEN` as env variable (https://circleci.com/docs/api/#add-an-api-token) diff --git a/plugins/circleci/dev/index.tsx b/plugins/circleci/dev/index.tsx index 4bf67d5cb2..812a5585d4 100644 --- a/plugins/circleci/dev/index.tsx +++ b/plugins/circleci/dev/index.tsx @@ -16,13 +16,5 @@ import { createDevApp } from '@backstage/dev-utils'; import { plugin } from '../src/plugin'; -import { circleCIApiRef, CircleCIApi } from '../src/api'; -createDevApp() - .registerPlugin(plugin) - .registerApi({ - api: circleCIApiRef, - deps: {}, - factory: () => new CircleCIApi(), - }) - .render(); +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 10278fc065..24dc8a40e1 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-circleci", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/catalog-model": "^0.1.1-alpha.22", - "@backstage/core": "^0.1.1-alpha.22", - "@backstage/plugin-catalog": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@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", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -38,15 +38,17 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", "@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/react-lazylog": "^4.5.0", - "jest-fetch-mock": "^3.0.3" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" }, "files": [ "dist" diff --git a/plugins/circleci/src/api/index.ts b/plugins/circleci/src/api/index.ts index da4c811812..c71be6b781 100644 --- a/plugins/circleci/src/api/index.ts +++ b/plugins/circleci/src/api/index.ts @@ -26,7 +26,7 @@ import { BuildSummary, GitType, } from 'circleci-api'; -import { createApiRef } from '@backstage/core'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; export { GitType }; export type { BuildWithSteps, BuildStepAction, BuildSummary }; @@ -36,15 +36,28 @@ export const circleCIApiRef = createApiRef({ description: 'Used by the CircleCI plugin to make requests', }); +const DEFAULT_PROXY_PATH = '/circleci/api'; + +type Options = { + discoveryApi: DiscoveryApi; + /** + * Path to use for requests via the proxy, defaults to /circleci/api + */ + proxyPath?: string; +}; + export class CircleCIApi { - apiUrl: string; - constructor(apiUrl: string = '/circleci/api') { - this.apiUrl = apiUrl; + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPath: string; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH; } async retry(buildNumber: number, options: Partial) { return postBuildActions('', buildNumber, BuildAction.RETRY, { - circleHost: this.apiUrl, + circleHost: await this.getApiUrl(), ...options.vcs, }); } @@ -59,19 +72,24 @@ export class CircleCIApi { offset, }, vcs: {}, - circleHost: this.apiUrl, + circleHost: await this.getApiUrl(), ...options, }); } async getUser(options: Partial) { - return getMe('', { circleHost: this.apiUrl, ...options }); + return getMe('', { circleHost: await this.getApiUrl(), ...options }); } async getBuild(buildNumber: number, options: Partial) { return getFullBuild('', buildNumber, { - circleHost: this.apiUrl, + circleHost: await this.getApiUrl(), ...options.vcs, }); } + + private async getApiUrl() { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return proxyUrl + this.proxyPath; + } } diff --git a/plugins/circleci/src/assets/screenshot-1.png b/plugins/circleci/src/assets/screenshot-1.png index db99230a65..bef8a5a06f 100644 Binary files a/plugins/circleci/src/assets/screenshot-1.png and b/plugins/circleci/src/assets/screenshot-1.png differ diff --git a/plugins/circleci/src/assets/screenshot-2.png b/plugins/circleci/src/assets/screenshot-2.png index 4f9ddcaec6..e4b8299013 100644 Binary files a/plugins/circleci/src/assets/screenshot-2.png and b/plugins/circleci/src/assets/screenshot-2.png differ diff --git a/plugins/circleci/src/components/Router.tsx b/plugins/circleci/src/components/Router.tsx index 282eac9111..41764b3d7d 100644 --- a/plugins/circleci/src/components/Router.tsx +++ b/plugins/circleci/src/components/Router.tsx @@ -24,8 +24,7 @@ import { Entity } from '@backstage/catalog-model'; import { WarningPanel } from '@backstage/core'; export const isPluginApplicableToEntity = (entity: Entity) => - Boolean(entity.metadata.annotations?.[CIRCLECI_ANNOTATION]) && - entity.metadata.annotations?.[CIRCLECI_ANNOTATION] !== ''; + Boolean(entity.metadata.annotations?.[CIRCLECI_ANNOTATION]); export const Router = ({ entity }: { entity: Entity }) => !isPluginApplicableToEntity(entity) ? ( diff --git a/plugins/circleci/src/plugin.ts b/plugins/circleci/src/plugin.ts index f966caa383..c5bb122ed3 100644 --- a/plugins/circleci/src/plugin.ts +++ b/plugins/circleci/src/plugin.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createPlugin, createApiFactory, configApiRef } from '@backstage/core'; +import { + createPlugin, + createApiFactory, + discoveryApiRef, +} from '@backstage/core'; import { circleCIApiRef, CircleCIApi } from './api'; export const plugin = createPlugin({ @@ -22,11 +26,8 @@ export const plugin = createPlugin({ apis: [ createApiFactory({ api: circleCIApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => - new CircleCIApi( - `${configApi.getString('backend.baseUrl')}/proxy/circleci/api`, - ), + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new CircleCIApi({ discoveryApi }), }), ], }); diff --git a/plugins/cloudbuild/.eslintrc.js b/plugins/cloudbuild/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/cloudbuild/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/cloudbuild/README.md b/plugins/cloudbuild/README.md new file mode 100644 index 0000000000..5f1f6519dc --- /dev/null +++ b/plugins/cloudbuild/README.md @@ -0,0 +1,13 @@ +# Google Cloud Build + +Welcome to the Google Cloud Build plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/cloudbuild](http://localhost:3000/cloudbuild). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/cloudbuild/dev/index.tsx b/plugins/cloudbuild/dev/index.tsx new file mode 100644 index 0000000000..264d6f801f --- /dev/null +++ b/plugins/cloudbuild/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/cloudbuild/package.json b/plugins/cloudbuild/package.json new file mode 100644 index 0000000000..1caac1ecc5 --- /dev/null +++ b/plugins/cloudbuild/package.json @@ -0,0 +1,57 @@ +{ + "name": "@backstage/plugin-cloudbuild", + "version": "0.1.1-alpha.24", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "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.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", + "@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.4.1", + "moment": "^2.27.0", + "qs": "^6.9.4", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-lazylog": "^4.5.3", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", + "@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" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/cloudbuild/src/api/CloudbuildApi.ts b/plugins/cloudbuild/src/api/CloudbuildApi.ts new file mode 100644 index 0000000000..5aecf970ef --- /dev/null +++ b/plugins/cloudbuild/src/api/CloudbuildApi.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; +import { + ActionsListWorkflowRunsForRepoResponseData, + ActionsGetWorkflowResponseData, +} from '../api/types'; + +export const cloudbuildApiRef = createApiRef({ + id: 'plugin.cloudbuild.service', + description: 'Used by the Cloudbuild plugin to make requests', +}); + +export type CloudbuildApi = { + listWorkflowRuns: (request: { + projectId: string; + }) => Promise; + getWorkflow: ({ + projectId, + id, + }: { + projectId: string; + id: string; + }) => Promise; + getWorkflowRun: ({ + projectId, + id, + }: { + projectId: string; + id: string; + }) => Promise; + reRunWorkflow: ({ + projectId, + runId, + }: { + projectId: string; + runId: string; + }) => Promise; +}; diff --git a/plugins/cloudbuild/src/api/CloudbuildClient.ts b/plugins/cloudbuild/src/api/CloudbuildClient.ts new file mode 100644 index 0000000000..0c3223aa04 --- /dev/null +++ b/plugins/cloudbuild/src/api/CloudbuildClient.ts @@ -0,0 +1,122 @@ +/* + * 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 { CloudbuildApi } from './CloudbuildApi'; +import { + ActionsListWorkflowRunsForRepoResponseData, + ActionsGetWorkflowResponseData, +} from '../api/types'; +import { OAuthApi } from '@backstage/core'; + +export class CloudbuildClient implements CloudbuildApi { + constructor(private readonly googleAuthApi: OAuthApi) {} + + async reRunWorkflow({ + projectId, + runId, + }: { + projectId: string; + runId: string; + }): Promise { + await fetch( + `https://cloudbuild.googleapis.com/v1/projects/${encodeURIComponent( + projectId, + )}/builds/${encodeURIComponent(runId)}:retry`, + { + headers: new Headers({ + Accept: '*/*', + Authorization: `Bearer ${await this.getToken()}`, + }), + }, + ); + } + async listWorkflowRuns({ + projectId, + }: { + projectId: string; + }): Promise { + const workflowRuns = await fetch( + `https://cloudbuild.googleapis.com/v1/projects/${encodeURIComponent( + projectId, + )}/builds`, + { + headers: new Headers({ + Accept: '*/*', + Authorization: `Bearer ${await this.getToken()}`, + }), + }, + ); + + const builds: ActionsListWorkflowRunsForRepoResponseData = await workflowRuns.json(); + + return builds; + } + async getWorkflow({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise { + const workflow = await fetch( + `https://cloudbuild.googleapis.com/v1/projects/${encodeURIComponent( + projectId, + )}/builds/${encodeURIComponent(id)}`, + { + headers: new Headers({ + Accept: '*/*', + Authorization: `Bearer ${await this.getToken()}`, + }), + }, + ); + + const build: ActionsGetWorkflowResponseData = await workflow.json(); + + return build; + } + async getWorkflowRun({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise { + const workflow = await fetch( + `https://cloudbuild.googleapis.com/v1/projects/${encodeURIComponent( + projectId, + )}/builds/${encodeURIComponent(id)}`, + { + headers: new Headers({ + Accept: '*/*', + Authorization: `Bearer ${await this.getToken()}`, + }), + }, + ); + const build: ActionsGetWorkflowResponseData = await workflow.json(); + + return build; + } + + async getToken(): Promise { + // NOTE(freben - gcp-projects): There's a .read-only variant of this scope that we could + // use for readonly operations, but that means we would ask the user for a + // second auth during creation and I decided to keep the wider scope for + // all ops for now + return this.googleAuthApi.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform', + ); + } +} diff --git a/plugins/cloudbuild/src/api/index.ts b/plugins/cloudbuild/src/api/index.ts new file mode 100644 index 0000000000..643eecb818 --- /dev/null +++ b/plugins/cloudbuild/src/api/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 './CloudbuildApi'; +export * from './CloudbuildClient'; +export * from './types'; diff --git a/plugins/cloudbuild/src/api/types.ts b/plugins/cloudbuild/src/api/types.ts new file mode 100644 index 0000000000..7c64fb52b8 --- /dev/null +++ b/plugins/cloudbuild/src/api/types.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. + */ + +export interface ActionsListWorkflowRunsForRepoResponseData { + builds: ActionsGetWorkflowResponseData[]; +} + +export type ActionsGetWorkflowResponseData = { + id: string; + status: string; + source: Source; + createTime: string; + startTime: string; + steps: Step[]; + timeout: string; + projectId: string; + logsBucket: string; + sourceProvenance: SourceProvenance; + buildTriggerId: string; + options: Options; + logUrl: string; + substitutions: Substitutions; + tags: string[]; + queueTtl: string; + name: string; + finishTime: any; + results: Results; + timing: Timing2; +}; + +export interface Step { + name: string; + args: string[]; + id: string; + waitFor: string[]; + entrypoint: string; + volumes: Volume[]; + dir: string; + timing: Timing; + status: string; + pullTiming: PullTiming; +} + +export interface Timing2 { + BUILD: BUILD; + FETCHSOURCE: FETCHSOURCE; +} + +export interface SourceProvenance { + resolvedStorageSource: {}; + fileHashes: {}; +} + +export interface Options { + machineType: string; + substitutionOption: string; + logging: string; + dynamicSubstitutions: boolean; +} + +export interface Substitutions { + COMMIT_SHA: string; + SHORT_SHA: string; + BRANCH_NAME: string; + REPO_NAME: string; + REVISION_ID: string; +} + +export interface Results { + buildStepImages: string[]; + buildStepOutputs: string[]; +} + +export interface BUILD { + startTime: string; + endTime: string; +} + +export interface FETCHSOURCE { + startTime: string; + endTime: string; +} + +export interface StorageSource { + bucket: string; + object: string; +} + +export interface Source { + storageSource: StorageSource; +} + +export interface Volume { + name: string; + path: string; +} + +export interface Timing { + startTime: string; + endTime: string; +} + +export interface PullTiming { + startTime: string; + endTime: string; +} + +export interface ResolvedStorageSource { + bucket: string; + object: string; + generation: string; +} diff --git a/plugins/cloudbuild/src/components/Cards/Cards.tsx b/plugins/cloudbuild/src/components/Cards/Cards.tsx new file mode 100644 index 0000000000..bbfb0b59c3 --- /dev/null +++ b/plugins/cloudbuild/src/components/Cards/Cards.tsx @@ -0,0 +1,116 @@ +/* + * 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 } from 'react'; +import { useWorkflowRuns } from '../useWorkflowRuns'; +import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; +import { Entity } from '@backstage/catalog-model'; +import { WorkflowRunStatus } from '../WorkflowRunStatus'; +import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core'; +import { + InfoCard, + StructuredMetadataTable, + errorApiRef, + useApi, + WarningPanel, +} from '@backstage/core'; +import ExternalLinkIcon from '@material-ui/icons/Launch'; +import { CLOUDBUILD_ANNOTATION } from '../useProjectName'; + +const useStyles = makeStyles({ + externalLinkIcon: { + fontSize: 'inherit', + verticalAlign: 'bottom', + }, +}); + +const WidgetContent = ({ + error, + loading, + lastRun, + branch, +}: { + error?: Error; + loading?: boolean; + lastRun: WorkflowRun; + branch: string; +}) => { + const classes = useStyles(); + if (error) + return Couldn't fetch latest {branch} run; + if (loading) return ; + return ( + + + + ), + message: lastRun.message, + url: ( + + See more on Google{' '} + + + ), + }} + /> + ); +}; + +export const LatestWorkflowRunCard = ({ + entity, + branch = 'master', +}: { + entity: Entity; + branch: string; +}) => { + const errorApi = useApi(errorApiRef); + const projectId = entity?.metadata.annotations?.[CLOUDBUILD_ANNOTATION] || ''; + + const [{ runs, loading, error }] = useWorkflowRuns({ + projectId, + }); + const lastRun = runs?.[0] ?? ({} as WorkflowRun); + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + + return ( + + + + ); +}; + +export const LatestWorkflowsForBranchCard = ({ + entity, + branch = 'master', +}: { + entity: Entity; + branch: string; +}) => ( + + + +); diff --git a/plugins/cloudbuild/src/components/Cards/index.ts b/plugins/cloudbuild/src/components/Cards/index.ts new file mode 100644 index 0000000000..8c987ea1d5 --- /dev/null +++ b/plugins/cloudbuild/src/components/Cards/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 { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards'; diff --git a/plugins/cloudbuild/src/components/Router.tsx b/plugins/cloudbuild/src/components/Router.tsx new file mode 100644 index 0000000000..b2653a05f2 --- /dev/null +++ b/plugins/cloudbuild/src/components/Router.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Routes, Route } from 'react-router'; +import { rootRouteRef, buildRouteRef } from '../plugin'; +import { WorkflowRunDetails } from './WorkflowRunDetails'; +import { WorkflowRunsTable } from './WorkflowRunsTable'; +import { CLOUDBUILD_ANNOTATION } from './useProjectName'; +import { WarningPanel } from '@backstage/core'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[CLOUDBUILD_ANNOTATION]); + +export const Router = ({ entity }: { entity: Entity }) => + // TODO(shmidt-i): move warning to a separate standardized component + !isPluginApplicableToEntity(entity) ? ( + +
{CLOUDBUILD_ANNOTATION}
annotation is missing on the entity. +
+ ) : ( + + } + /> + } + /> + ) + + ); diff --git a/plugins/cloudbuild/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx b/plugins/cloudbuild/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx new file mode 100644 index 0000000000..94c6fa4d25 --- /dev/null +++ b/plugins/cloudbuild/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx @@ -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. + */ +import { Entity } from '@backstage/catalog-model'; +import { Link, WarningPanel } from '@backstage/core'; +import { + Breadcrumbs, + LinearProgress, + Link as MaterialLink, + makeStyles, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Theme, + Typography, +} from '@material-ui/core'; +import ExternalLinkIcon from '@material-ui/icons/Launch'; +import qs from 'qs'; +import React from 'react'; +import { useProjectName } from '../useProjectName'; +import { WorkflowRunStatus } from '../WorkflowRunStatus'; +import { useWorkflowRunsDetails } from './useWorkflowRunsDetails'; + +const useStyles = makeStyles(theme => ({ + root: { + maxWidth: 720, + margin: theme.spacing(2), + }, + title: { + padding: theme.spacing(1, 0, 2, 0), + }, + table: { + padding: theme.spacing(1), + }, + accordionDetails: { + padding: 0, + }, + button: { + order: -1, + marginRight: 0, + marginLeft: '-20px', + }, + externalLinkIcon: { + fontSize: 'inherit', + verticalAlign: 'bottom', + }, +})); + +export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { + const { value: projectName, loading, error } = useProjectName(entity); + const [projectId] = (projectName ?? '/').split('/'); + + const details = useWorkflowRunsDetails(projectId); + + const classes = useStyles(); + if (error) { + return ( + + Failed to load build, {error.message}. + + ); + } else if (loading) { + return ; + } else if (details.value?.logUrl === undefined) { + return ; + } + + const serviceAccount = qs.parse(new URL(details.value?.logUrl).search, { + ignoreQueryPrefix: true, + }).project; + + return ( +
+ + Workflow runs + Workflow run details + + + + + + + Branch + + {details.value?.substitutions.BRANCH_NAME} + + + + Message + + {details.value?.substitutions.REPO_NAME} + + + + Commit ID + + {details.value?.substitutions.COMMIT_SHA} + + + + Status + + + + + + + + Service Account + + + {`${serviceAccount}`}@cloudbuild.gserviceaccount.com + + + + + Links + + + {details.value?.logUrl && ( + + Workflow runs on Google{' '} + + + )} + + + +
+
+
+ ); +}; diff --git a/plugins/cloudbuild/src/components/WorkflowRunDetails/index.ts b/plugins/cloudbuild/src/components/WorkflowRunDetails/index.ts new file mode 100644 index 0000000000..2886a26740 --- /dev/null +++ b/plugins/cloudbuild/src/components/WorkflowRunDetails/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 { WorkflowRunDetails } from './WorkflowRunDetails'; diff --git a/plugins/cloudbuild/src/components/WorkflowRunDetails/useWorkflowRunJobs.ts b/plugins/cloudbuild/src/components/WorkflowRunDetails/useWorkflowRunJobs.ts new file mode 100644 index 0000000000..2150e01d98 --- /dev/null +++ b/plugins/cloudbuild/src/components/WorkflowRunDetails/useWorkflowRunJobs.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 { useAsync } from 'react-use'; +import { ActionsListWorkflowRunsForRepoResponseData } from '../../api/types'; + +export const useWorkflowRunJobs = (jobsUrl?: string) => { + const jobs = useAsync(async (): Promise< + ActionsListWorkflowRunsForRepoResponseData + > => { + if (jobsUrl === undefined) { + return { + builds: [], + }; + } + + const data = await fetch(jobsUrl).then(d => d.json()); + return data; + }, [jobsUrl]); + return jobs; +}; diff --git a/plugins/cloudbuild/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts b/plugins/cloudbuild/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts new file mode 100644 index 0000000000..ae5e750d15 --- /dev/null +++ b/plugins/cloudbuild/src/components/WorkflowRunDetails/useWorkflowRunsDetails.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 { useApi } from '@backstage/core'; +import { useParams } from 'react-router-dom'; +import { useAsync } from 'react-use'; +import { cloudbuildApiRef } from '../../api'; + +export const useWorkflowRunsDetails = (projectId: string) => { + const api = useApi(cloudbuildApiRef); + const { id } = useParams(); + const details = useAsync(async () => { + return projectId + ? api.getWorkflowRun({ + projectId, + id: id, + }) + : Promise.reject('No projectId provided'); + }, [projectId, id]); + return details; +}; diff --git a/plugins/cloudbuild/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx b/plugins/cloudbuild/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx new file mode 100644 index 0000000000..45a8fc662c --- /dev/null +++ b/plugins/cloudbuild/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx @@ -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 { + StatusPending, + StatusRunning, + StatusOK, + StatusAborted, + StatusError, +} from '@backstage/core'; +import React from 'react'; + +export const WorkflowRunStatus = ({ + status, +}: { + status: string | undefined; +}) => { + if (status === undefined) return null; + switch (status.toLowerCase()) { + case 'queued': + return ( + <> + Queued + + ); + case 'working': + return ( + <> + In progress + + ); + case 'success': + return ( + <> + Completed + + ); + case 'cancelled': + return ( + <> + Cancelled + + ); + case 'failure': + return ( + <> + Failed + + ); + default: + return ( + <> + Pending + + ); + } +}; diff --git a/plugins/cloudbuild/src/components/WorkflowRunStatus/index.ts b/plugins/cloudbuild/src/components/WorkflowRunStatus/index.ts new file mode 100644 index 0000000000..8ebca32cbd --- /dev/null +++ b/plugins/cloudbuild/src/components/WorkflowRunStatus/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 { WorkflowRunStatus } from './WorkflowRunStatus'; diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx new file mode 100644 index 0000000000..254e6f89ed --- /dev/null +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -0,0 +1,185 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FC } from 'react'; +import { Link, Typography, Box, IconButton, Tooltip } from '@material-ui/core'; +import RetryIcon from '@material-ui/icons/Replay'; +import GoogleIcon from '@material-ui/icons/CloudCircle'; +import { Link as RouterLink, generatePath } from 'react-router-dom'; +import { Table, TableColumn } from '@backstage/core'; +import { useWorkflowRuns } from '../useWorkflowRuns'; +import { WorkflowRunStatus } from '../WorkflowRunStatus'; +import SyncIcon from '@material-ui/icons/Sync'; +import { useProjectName } from '../useProjectName'; +import { Entity } from '@backstage/catalog-model'; +import { Substitutions } from '../../api/types'; +import { buildRouteRef } from '../../plugin'; +import moment from 'moment'; + +export type WorkflowRun = { + id: string; + message: string; + url?: string; + googleUrl?: string; + status: string; + substitutions: Substitutions; + createTime: string; + rerun: () => void; +}; + +const generatedColumns: TableColumn[] = [ + { + title: 'Status', + width: '150px', + + render: (row: Partial) => ( + + + + ), + }, + { + title: 'Build', + field: 'id', + type: 'numeric', + width: '150px', + render: (row: Partial) => ( + +

{row.id?.substring(0, 8)}

+
+ ), + }, + { + title: 'Source', + field: 'source', + highlight: true, + width: '200px', + render: (row: Partial) => ( + + {row.message} + + ), + }, + { + title: 'Ref', + render: (row: Partial) => ( + +

{row.substitutions?.BRANCH_NAME}

+
+ ), + }, + { + title: 'Commit', + render: (row: Partial) => ( + +

{row.substitutions?.SHORT_SHA}

+
+ ), + }, + { + title: 'Created', + render: (row: Partial) => ( + +

{moment(row.createTime).format('DD-MM-YYYY hh:mm:ss')}

+
+ ), + }, + { + title: 'Actions', + render: (row: Partial) => ( + + + + + + ), + width: '10%', + }, +]; + +type Props = { + loading: boolean; + retry: () => void; + runs?: WorkflowRun[]; + projectName: string; + page: number; + onChangePage: (page: number) => void; + total: number; + pageSize: number; + onChangePageSize: (pageSize: number) => void; +}; + +export const WorkflowRunsTableView: FC = ({ + projectName, + loading, + pageSize, + page, + retry, + runs, + onChangePage, + onChangePageSize, + total, +}) => { + return ( + , + tooltip: 'Reload workflow runs', + isFreeAction: true, + onClick: () => retry(), + }, + ]} + data={runs ?? []} + onChangePage={onChangePage} + onChangeRowsPerPage={onChangePageSize} + style={{ width: '100%' }} + title={ + + + + {projectName} + + } + columns={generatedColumns} + /> + ); +}; + +export const WorkflowRunsTable = ({ entity }: { entity: Entity }) => { + const { value: projectName, loading } = useProjectName(entity); + const [projectId] = (projectName ?? '/').split('/'); + + const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns({ + projectId, + }); + + return ( + + ); +}; diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts b/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts new file mode 100644 index 0000000000..4e21cc67c2 --- /dev/null +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/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 { WorkflowRunsTable, WorkflowRunsTableView } from './WorkflowRunsTable'; +export type { WorkflowRun } from './WorkflowRunsTable'; diff --git a/plugins/cloudbuild/src/components/useProjectName.ts b/plugins/cloudbuild/src/components/useProjectName.ts new file mode 100644 index 0000000000..0c227f690d --- /dev/null +++ b/plugins/cloudbuild/src/components/useProjectName.ts @@ -0,0 +1,27 @@ +/* + * 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 { useAsync } from 'react-use'; +import { Entity } from '@backstage/catalog-model'; + +export const CLOUDBUILD_ANNOTATION = 'google.com/cloudbuild-project-slug'; + +export const useProjectName = (entity: Entity) => { + const { value, loading, error } = useAsync(async () => { + return entity?.metadata.annotations?.[CLOUDBUILD_ANNOTATION] ?? ''; + }); + return { value, loading, error }; +}; diff --git a/plugins/cloudbuild/src/components/useWorkflowRuns.ts b/plugins/cloudbuild/src/components/useWorkflowRuns.ts new file mode 100644 index 0000000000..b6d6c2c620 --- /dev/null +++ b/plugins/cloudbuild/src/components/useWorkflowRuns.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 { useState } from 'react'; +import { useAsyncRetry } from 'react-use'; +import { WorkflowRun } from './WorkflowRunsTable/WorkflowRunsTable'; +import { cloudbuildApiRef } from '../api/CloudbuildApi'; +import { useApi, errorApiRef } from '@backstage/core'; +import { ActionsListWorkflowRunsForRepoResponseData } from '../api/types'; + +export function useWorkflowRuns({ projectId }: { projectId: string }) { + const api = useApi(cloudbuildApiRef); + const errorApi = useApi(errorApiRef); + + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(5); + + const { loading, value: runs, retry, error } = useAsyncRetry< + WorkflowRun[] + >(async () => { + return api + .listWorkflowRuns({ + projectId, + }) + .then( + ( + workflowRunsData: ActionsListWorkflowRunsForRepoResponseData, + ): WorkflowRun[] => { + setTotal(workflowRunsData.builds.length); + // Transformation here + return workflowRunsData.builds.map(run => ({ + message: run.substitutions.REPO_NAME, + id: run.id, + rerun: async () => { + try { + await api.reRunWorkflow({ + projectId, + runId: run.id, + }); + } catch (e) { + errorApi.post(e); + } + }, + substitutions: run.substitutions, + source: { + branchName: run.substitutions.REPO_NAME, + commit: { + hash: run.substitutions.COMMIT_SHA, + url: run.substitutions.REPO_NAME, + }, + }, + status: run.status, + url: run.logUrl, + googleUrl: run.logUrl, + createTime: run.createTime, + })); + }, + ); + }, [page, pageSize, projectId]); + + return [ + { + page, + pageSize, + loading, + runs, + projectName: `${projectId}`, + total, + error, + }, + { + runs, + setPage, + setPageSize, + retry, + }, + ] as const; +} diff --git a/plugins/cloudbuild/src/index.ts b/plugins/cloudbuild/src/index.ts new file mode 100644 index 0000000000..616bb6b4e7 --- /dev/null +++ b/plugins/cloudbuild/src/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { plugin } from './plugin'; +export * from './api'; +export { Router, isPluginApplicableToEntity } from './components/Router'; +export * from './components/Cards'; +export { CLOUDBUILD_ANNOTATION } from './components/useProjectName'; diff --git a/plugins/cloudbuild/src/plugin.test.ts b/plugins/cloudbuild/src/plugin.test.ts new file mode 100644 index 0000000000..dae16f1d1b --- /dev/null +++ b/plugins/cloudbuild/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('cloudbuild', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/cloudbuild/src/plugin.ts b/plugins/cloudbuild/src/plugin.ts new file mode 100644 index 0000000000..ee997cc7ec --- /dev/null +++ b/plugins/cloudbuild/src/plugin.ts @@ -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 { + createPlugin, + createRouteRef, + createApiFactory, + googleAuthApiRef, +} from '@backstage/core'; +import { cloudbuildApiRef, CloudbuildClient } from './api'; + +export const rootRouteRef = createRouteRef({ + path: '', + title: 'Google Cloudbuild', +}); + +export const buildRouteRef = createRouteRef({ + path: ':id', + title: 'Cloudbuild Run', +}); + +export const plugin = createPlugin({ + id: 'cloudbuild', + apis: [ + createApiFactory({ + api: cloudbuildApiRef, + deps: { googleAuthApi: googleAuthApiRef }, + factory({ googleAuthApi }) { + return new CloudbuildClient(googleAuthApi); + }, + }), + ], +}); diff --git a/plugins/cloudbuild/src/setupTests.ts b/plugins/cloudbuild/src/setupTests.ts new file mode 100644 index 0000000000..4b4cdbdaaf --- /dev/null +++ b/plugins/cloudbuild/src/setupTests.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. + */ +import '@testing-library/jest-dom'; + +require('jest-fetch-mock').enableMocks(); diff --git a/plugins/cost-insights/.eslintrc.js b/plugins/cost-insights/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/cost-insights/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/cost-insights/.npmrc b/plugins/cost-insights/.npmrc new file mode 100644 index 0000000000..214c29d139 --- /dev/null +++ b/plugins/cost-insights/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md new file mode 100644 index 0000000000..01dd415ec2 --- /dev/null +++ b/plugins/cost-insights/README.md @@ -0,0 +1,106 @@ +# Cost Insights + +Cost Insights is a plugin to help engineers visualize, understand and optimize their cloud costs. The Cost Insights page shows daily cost data for a team, trends over time, and comparisons with the business metrics you care about. + +At Spotify, we find that cloud costs are optimized organically when: + +- Engineers see cost data in their daily work (that is, in Backstage). +- It's clear when cloud costs need attention. +- The data is shown in software terms familiar to them. +- Alerts and recommendations are targeted and actionable. + +Cost Insights shows trends over time, at the granularity of Backstage catalog entities - rather than the cloud provider's concepts. It can be used to troubleshoot cost anomalies, and promote cost-saving infrastructure migrations. + +## Install + +```bash +yarn add @backstage/plugin-cost-insights +``` + +## Setup + +1. Configure `app-config.yaml`. See [Configuration](#configuration). + +2. Create a CostInsights client. Clients must implement the CostInsightsApi interface. See the [API file](https://github.com/spotify/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts) for required methods and documentation. + +```ts +// path/to/CostInsightsClient.ts +import { CostInsightsApi } from '@backstage/plugin-cost-insights'; + +export class CostInsightsClient implements CostInsightsApi { ... } +``` + +3. Import the client and the CostInsights plugin API to your Backstage instance. + +```ts +// packages/app/src/api.ts +import { createApiFactory } from '@backstage/core'; +import { costInsightsApiRef } from '@backstage/plugin-cost-insights'; +import { CostInsightsClient } from './path/to/file'; + +export const apis = [ + createApiFactory({ + api: costInsightsApiRef, + deps: {}, + factory: () => new CostInsightsClient(), + }), +]; +``` + +4. Add cost-insights to your Backstage plugins. + +```ts +// packages/app/src/plugins.ts +export { plugin as CostInsights } from '@backstage/plugin-cost-insights'; +``` + +## Configuration + +Cost Insights has only two required configuration fields: a map of cloud `products` for showing cost breakdowns and `engineerCost` - the average yearly cost of an engineer including benefits. Products must be defined as keys on the `products` field. + +You can optionally supply a product `icon` to display in Cost Insights navigation. See the [type file](https://github.com/spotify/backstage/blob/master/plugins/cost-insights/src/types/Icon.ts) for supported types and Material UI icon [mappings](https://github.com/spotify/backstage/blob/master/plugins/cost-insights/src/utils/navigation.tsx). + +**Note:** Product keys should be unique and camelCased. Backstage does not support underscores in configuration keys. + +### Basic + +```yaml +## ./app-config.yaml +costInsights: + engineerCost: 200000 + products: + productA: + name: Some Cloud Product ## required + icon: storage + productB: + name: Some Other Cloud Product + icon: data +``` + +### Metrics (Optional) + +In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getDailyCost` and `getProjectCosts` API methods via the `metric` parameter. + +**Note:** Cost Insights displays daily cost without a metric by default. The dropdown text for this default can be overridden by assigning it a value on the `dailyCost` field. + +```yaml +## ./app-config.yaml +costInsights: + engineerCost: 200000 + products: + productA: + name: Some Cloud Product + icon: storage + productB: + name: Some Other Cloud Product + icon: data + metrics: + dailyCost: + name: Earth Rotation + metricA: + name: Metric A ## required + metricB: + name: Metric B + metricC: + name: Metric C +``` diff --git a/plugins/cost-insights/dev/index.tsx b/plugins/cost-insights/dev/index.tsx new file mode 100644 index 0000000000..812a5585d4 --- /dev/null +++ b/plugins/cost-insights/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/cost-insights/package.json b/plugins/cost-insights/package.json new file mode 100644 index 0000000000..a9b6a646c4 --- /dev/null +++ b/plugins/cost-insights/package.json @@ -0,0 +1,59 @@ +{ + "name": "@backstage/plugin-cost-insights", + "version": "0.1.1-alpha.24", + "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/config": "^0.1.1-alpha.24", + "@backstage/core": "^0.1.1-alpha.24", + "@backstage/test-utils": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@material-ui/styles": "^4.9.6", + "canvas": "^2.6.1", + "classnames": "^2.2.6", + "history": "^5.0.0", + "moment": "^2.27.0", + "qs": "^6.9.4", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3", + "recharts": "^1.8.5" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", + "@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/recharts": "^1.8.14", + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts new file mode 100644 index 0000000000..b7628e2f9e --- /dev/null +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; +import { Alert, Cost, Duration, Group, Project, ProductCost } from '../types'; + +export type CostInsightsApi = { + /** + * Get a list of groups the given user belongs to. These may be LDAP groups or similar + * organizational groups. Cost Insights is designed to show costs based on group membership; + * if a user has multiple groups, they are able to switch between groups to see costs for each. + * + * This method should be removed once the Backstage identity plugin provides the same concept. + */ + getUserGroups(userId: string): Promise; + + /** + * Get a list of cloud billing entities that belong to this group (projects in GCP, AWS has a + * similar concept in billing accounts). These act as filters for the displayed costs, users can + * choose whether they see all costs for a group, or those from a particular owned project. + */ + getGroupProjects(group: string): Promise; + + /** + * Get daily cost aggregations for a given group and interval timeframe. + * + * The return type includes an array of daily cost aggregations as well as statistics about the + * change in cost over the intervals. Calculating these statistics requires us to bucket costs + * into two or more time periods, hence a repeating interval format rather than just a start and + * end date. + * + * The rate of change in this comparison allows teams to reason about their cost growth (or + * reduction) and compare it to metrics important to the business. + * + * @param group The group id from getUserGroups or query parameters + * @param metric A metric from the cost-insights configuration in app-config.yaml. The backend + * should divide the actual daily cost by the corresponding metric for the same date. + * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ + getGroupDailyCost( + group: string, + metric: string | null, + intervals: string, + ): Promise; + + /** + * Get daily cost aggregations for a given billing entity (project in GCP, AWS has a similar + * concept in billing accounts) and interval timeframe. + * + * The return type includes an array of daily cost aggregations as well as statistics about the + * change in cost over the intervals. Calculating these statistics requires us to bucket costs + * into two or more time periods, hence a repeating interval format rather than just a start and + * end date. + * + * The rate of change in this comparison allows teams to reason about the project's cost growth + * (or reduction) and compare it to metrics important to the business. + * + * @param project The project id from getGroupProjects or query parameters + * @param metric A metric from the cost-insights configuration in app-config.yaml. The backend + * should divide the actual daily cost by the corresponding metric for the same date. + * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ + getProjectDailyCost( + project: string, + metric: string | null, + intervals: string, + ): Promise; + + /** + * Get cost aggregations for a particular cloud product and interval timeframe. This includes + * total cost for the product, as well as a breakdown of particular entities that incurred cost + * in this product. The type of entity depends on the product - it may be deployed services, + * storage buckets, managed database instances, etc. + * + * The time period is supplied as a Duration rather than intervals, since this is always expected + * to return data for two bucketed time period (e.g. month vs month, or quarter vs quarter). + * + * @param product The product from the cost-insights configuration in app-config.yaml + * @param group + * @param duration A time duration, such as P1M. See the Duration type for a detailed explanation + * of how the durations are interpreted in Cost Insights. + */ + getProductInsights( + product: string, + group: string, + duration: Duration, + ): Promise; + + /** + * Get current cost alerts for a given group. These show up as Action Items for the group on the + * Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure + * migrations, or cost-related warnings, such as an unexpected billing anomaly. + */ + getAlerts(group: string): Promise; +}; + +export const costInsightsApiRef = createApiRef({ + id: 'plugin.costinsights.service', + description: 'Provides cost data and alerts for the cost-insights plugin', +}); diff --git a/plugins/identity-backend/src/adapters/index.ts b/plugins/cost-insights/src/api/index.ts similarity index 94% rename from plugins/identity-backend/src/adapters/index.ts rename to plugins/cost-insights/src/api/index.ts index 357391a25b..d231570e9b 100644 --- a/plugins/identity-backend/src/adapters/index.ts +++ b/plugins/cost-insights/src/api/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './StaticJsonAdapter'; +export * from './CostInsightsApi'; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx new file mode 100644 index 0000000000..6e6f935137 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.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 { renderInTestApp } from '@backstage/test-utils'; +import AlertActionCard from './AlertActionCard'; +import { AlertType, ProjectGrowthAlert } from '../../types'; +import { getAlertText } from '../../utils/alerts'; +import { MockScrollProvider } from '../../utils/tests'; + +const alert = { + id: AlertType.ProjectGrowth, + aggregation: [500000.8, 970502.8], + project: 'test-project', + periodStart: '2019-10-01', + periodEnd: '2020-03-31', + change: { ratio: 120, amount: 120000 }, + products: [], +} as ProjectGrowthAlert; + +describe('', () => { + it('Renders an alert', async () => { + const rendered = await renderInTestApp( + + , + , + ); + + expect(rendered.getByText('1')).toBeInTheDocument(); + const text = getAlertText(alert); + expect(text).toBeDefined(); + if (text) { + expect(rendered.getByText(text.title)).toBeInTheDocument(); + expect(rendered.getByText(text.subtitle)).toBeInTheDocument(); + } + }); +}); diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx new file mode 100644 index 0000000000..f568f999f8 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Avatar, Card, CardHeader } from '@material-ui/core'; +import { useScroll } from '../../hooks'; +import { Alert } from '../../types'; +import { getAlertText, getAlertNavigation } from '../../utils/alerts'; +import { + useAlertActionCardStyles as useStyles, + useAlertActionCardHeader as useHeaderStyles, +} from '../../utils/styles'; + +type AlertActionCardProps = { + alert: Alert; + number: number; +}; + +const AlertActionCard = ({ alert, number }: AlertActionCardProps) => { + const { scrollIntoView } = useScroll(getAlertNavigation(alert, number)); + const headerClasses = useHeaderStyles(); + const text = getAlertText(alert); + const classes = useStyles(); + + return ( + + {number}} + title={text?.title} + subheader={text?.subtitle} + /> + + ); +}; + +export default AlertActionCard; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx new file mode 100644 index 0000000000..4ddc26e040 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FC, Fragment } from 'react'; +import { Paper, Divider } from '@material-ui/core'; +import AlertActionCard from './AlertActionCard'; +import { Alert } from '../../types'; + +type AlertActionCardList = { + alerts: Array; +}; + +const AlertActionCardList: FC = ({ alerts }) => ( + + {alerts.map((alert, index) => ( + + + {index < alerts.length - 1 && } + + ))} + +); + +export default AlertActionCardList; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/index.ts b/plugins/cost-insights/src/components/AlertActionCardList/index.ts new file mode 100644 index 0000000000..cccffd2985 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertActionCardList/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 { default } from './AlertActionCardList'; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx new file mode 100644 index 0000000000..59dac3a758 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid } from '@material-ui/core'; +import AlertInsightsSection from './AlertInsightsSection'; +import AlertInsightsHeader from './AlertInsightsHeader'; +import { Alert } from '../../types'; +import { renderAlert } from '../../utils/alerts'; + +const title = "Your team's action items"; +const subtitle = + 'This section outlines suggested action items your team can address to improve cloud costs.'; + +type AlertInsightsProps = { + alerts: Array; +}; + +const AlertInsights = ({ alerts }: AlertInsightsProps) => ( + + + + + + {alerts.map((alert, index) => ( + + + + ))} + + +); + +export default AlertInsights; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx new file mode 100644 index 0000000000..b08ea01730 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx @@ -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 React from 'react'; +import { Box, Typography } from '@material-ui/core'; +import { useCostInsightsStyles as useStyles } from '../../utils/styles'; +import { useScroll } from '../../hooks'; +import { DefaultNavigation } from '../../utils/navigation'; + +type AlertInsightsHeaderProps = { + title: string; + subtitle: string; +}; + +const AlertInsightsHeader = ({ title, subtitle }: AlertInsightsHeaderProps) => { + const classes = useStyles(); + const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader); + return ( + + + + {title}{' '} + + đŸŽ¯ + + + + {subtitle} + + + ); +}; + +export default AlertInsightsHeader; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx new file mode 100644 index 0000000000..c00d924cf2 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.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 { Box, Button } from '@material-ui/core'; +import AlertInsightsSectionHeader from './AlertInsightsSectionHeader'; +import { + getAlertButtonText, + getAlertText, + getAlertUrl, +} from '../../utils/alerts'; +import { Alert, Currency } from '../../types'; +import { useCurrency } from '../../hooks'; + +type AlertInsightsSectionProps = { + alert: Alert; + number: number; + render: (alert: Alert, currency: Currency) => JSX.Element; +}; + +const AlertInsightsSection = ({ + alert, + number, + render, +}: AlertInsightsSectionProps) => { + const [currency] = useCurrency(); + const text = getAlertText(alert); + const url = getAlertUrl(alert); + const buttonText = getAlertButtonText(alert); + + return ( + + + + + {/* */} + + {render(alert, currency)} + + ); +}; + +export default AlertInsightsSection; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx new file mode 100644 index 0000000000..5d5f94b9be --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.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 { Avatar, Box, Typography, Grid } from '@material-ui/core'; +import { Alert } from '../../types'; +import { getAlertNavigation } from '../../utils/alerts'; +import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles'; +import { useScroll } from '../../hooks'; + +type AlertInsightsSectionHeaderProps = { + alert: Alert; + number: number; + title: string; + subtitle: string; +}; + +const AlertInsightsSectionHeader = ({ + alert, + number, + title, + subtitle, +}: AlertInsightsSectionHeaderProps) => { + const { ScrollAnchor } = useScroll(getAlertNavigation(alert, number)); + const classes = useStyles(); + return ( + + + + + {number} + + + {title} + {subtitle} + + + + ); +}; + +export default AlertInsightsSectionHeader; diff --git a/plugins/cost-insights/src/components/AlertInsights/index.ts b/plugins/cost-insights/src/components/AlertInsights/index.ts new file mode 100644 index 0000000000..5ed33f4e39 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/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 { default } from './AlertInsights'; diff --git a/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx new file mode 100644 index 0000000000..0a422c335a --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx @@ -0,0 +1,61 @@ +/* + * 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 { Box, Button, Container, makeStyles } from '@material-ui/core'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import { Header, Page, pageTheme } from '@backstage/core'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const useStyles = makeStyles(theme => ({ + root: { + gridArea: 'pageContent', + padding: theme.spacing(4), + }, +})); + +type AlertInstructionsLayoutProps = { + title: string; + children: ReactNode; +}; + +const AlertInstructionsLayout = ({ + title, + children, +}: AlertInstructionsLayoutProps) => { + const classes = useStyles(); + return ( + + +
+ + + + + {children} + + + + ); +}; + +export default AlertInstructionsLayout; diff --git a/plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts b/plugins/cost-insights/src/components/AlertInstructionsLayout/index.ts new file mode 100644 index 0000000000..4063fe06d9 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInstructionsLayout/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 { default } from './AlertInstructionsLayout'; diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx new file mode 100644 index 0000000000..a39c5bafc2 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChart.test.tsx @@ -0,0 +1,191 @@ +/* + * 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 { fireEvent } from '@testing-library/react'; +import BarChart, { BarChartProps } from './BarChart'; +import { BarChartData, ResourceData } from '../../types'; +import { createMockEntity } from '../../utils/mockData'; +import { resourceSort } from '../../utils/sort'; +import { renderInTestApp } from '@backstage/test-utils'; +import { TooltipItemProps } from '../Tooltip'; +import { costInsightsLightTheme } from '../../utils/styles'; + +const MockEntities = [...Array(10)].map((_, index) => + createMockEntity(() => ({ + id: `test-id-${index + 1}`, + // grow resource costs linearly for testing + aggregation: [index * 1000, (index + 1) * 1000], + })), +); + +const MockBarChartData: BarChartData = { + previousFill: costInsightsLightTheme.palette.yellow, + currentFill: costInsightsLightTheme.palette.darkBlue, + previousName: 'Last Period', + currentName: 'Current Period', +}; + +const MockResources: ResourceData[] = MockEntities.map(entity => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], +})); + +const MockTooltipItem = (payload: TooltipPayload): TooltipItemProps => ({ + label: payload.name, + value: payload.value as string, + fill: payload.fill as string, +}); + +const renderWithProps = ({ + responsive = false, + displayAmount = 6, + barChartData = MockBarChartData, + getTooltipItem = MockTooltipItem, + resources = MockResources, +}: BarChartProps) => { + return renderInTestApp( + , + ); +}; + +describe('', () => { + it('Renders without exploding', async () => { + const rendered = await renderWithProps({} as BarChartProps); + expect(rendered.getByText('test-id-10')).toBeInTheDocument(); + }); + + it('Should display only 6 resources by default, sorted by cost', async () => { + const rendered = await renderWithProps({} as BarChartProps); + + MockResources.sort(resourceSort).forEach((resource, index) => { + if (index < 6) { + expect(rendered.getByText(resource.name!)).toBeInTheDocument(); + } else { + expect(rendered.queryByText(resource.name!)).not.toBeInTheDocument(); + } + }); + }); + + describe('Stepper', () => { + it('should not display stepper if displaying less than 6 resources', async () => { + const rendered = await renderWithProps({ + resources: MockResources.slice(0, 3), + } as BarChartProps); + expect( + rendered.queryByTestId('bar-chart-stepper'), + ).not.toBeInTheDocument(); + }); + + it('should display stepper if displaying more than 6 resources', async () => { + const rendered = await renderWithProps({} as BarChartProps); + expect(rendered.queryByTestId('bar-chart-stepper')).toBeInTheDocument(); + }); + + it('should display the next step button if resources are remaining', async () => { + const rendered = await renderWithProps({} as BarChartProps); + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + expect( + rendered.queryByTestId('bar-chart-stepper-button-back'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('bar-chart-stepper-button-next'), + ).toBeInTheDocument(); + }); + + it('should display the correct amount of dots for the amount of resources', async () => { + const rendered = await renderWithProps({} as BarChartProps); + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(2); + }); + + it('should not display the stepper or stepper buttons when the amount of resources is equal to the displayMax', async () => { + const MockEqualEntities = [...Array(5)].map(createMockEntity); + const MockEqualResources = MockEqualEntities.map(entity => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], + })); + + const rendered = await renderWithProps({ + displayAmount: 5, + resources: MockEqualResources, + } as BarChartProps); + expect( + rendered.queryByTestId('bar-chart-stepper'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('bar-chart-stepper-button-back'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('bar-chart-stepper-button-next'), + ).not.toBeInTheDocument(); + }); + + it('should display the correct resources while scrolling', async () => { + const rendered = await renderWithProps({ + displayAmount: 7, + } as BarChartProps); + + const sortedByCost = MockResources.sort(resourceSort); + + sortedByCost.slice(0, 7).forEach(resource => { + expect(rendered.getByText(resource.name!)).toBeInTheDocument(); + }); + + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + fireEvent.click(rendered.getByTestId('bar-chart-stepper-button-next')); + + sortedByCost.slice(7, 12).forEach(resource => { + expect(rendered.getByText(resource.name!)).toBeInTheDocument(); + }); + }); + + it('should display the correct amount of dots for a large amount of resources while scrolling', async () => { + const MockLargeEntities = [...Array(68)].map(createMockEntity); + const MockLargeResources = MockLargeEntities.map(entity => ({ + name: entity.id, + previous: entity.aggregation[0], + current: entity.aggregation[1], + })); + const rendered = await renderWithProps({ + resources: MockLargeResources, + } as BarChartProps); + + fireEvent.mouseEnter(rendered.getByTestId('bar-chart-wrapper')); + + const NextButton = rendered.getByTestId('bar-chart-stepper-button-next'); + + [...Array(9)].forEach(() => { + fireEvent.click(NextButton); + expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(10); + }); + + [...Array(2)].forEach(() => { + fireEvent.click(NextButton); + expect(rendered.queryAllByTestId('bar-chart-step').length).toEqual(2); + }); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/BarChart/BarChart.tsx b/plugins/cost-insights/src/components/BarChart/BarChart.tsx new file mode 100644 index 0000000000..2f923304a8 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChart.tsx @@ -0,0 +1,175 @@ +/* + * 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, useCallback } from 'react'; +import { + Bar, + BarChart as RechartsBarChart, + CartesianGrid, + ContentRenderer, + TooltipProps, + ResponsiveContainer, + Tooltip as RechartsTooltip, + XAxis, + YAxis, + TooltipPayload, +} from 'recharts'; +import { Box, useTheme } from '@material-ui/core'; +import BarChartTick from './BarChartTick'; +import BarChartStepper from './BarChartStepper'; +import Tooltip, { TooltipItemProps } from '../Tooltip'; + +import { currencyFormatter } from '../../utils/formatters'; +import { + BarChartData, + Maybe, + notEmpty, + ResourceData, + DataKey, + CostInsightsTheme, +} from '../../types'; +import { useBarChartStyles } from '../../utils/styles'; +import { resourceSort } from '../../utils/sort'; + +export type BarChartProps = { + responsive?: boolean; + displayAmount?: number; + barChartData: BarChartData; + getTooltipItem: (payload: TooltipPayload) => Maybe; + resources: ResourceData[]; +}; + +const BarChart = ({ + responsive = true, + displayAmount = 6, + barChartData, + getTooltipItem, + resources, +}: BarChartProps) => { + const theme = useTheme(); + const styles = useBarChartStyles(theme); + const [activeChart, setActiveChart] = useState(false); + const [stepWindow, setStepWindow] = useState(() => [0, displayAmount]); + + const { previousFill, currentFill, previousName, currentName } = barChartData; + + const [stepStart, stepEnd] = stepWindow; + const steps = Math.ceil(resources.length / displayAmount); + const disableStepper = resources.length <= displayAmount; + + const sortedResources = resources + .sort(resourceSort) + .slice(stepStart, stepEnd); + + // Pin the domain to the largest value in the series. + // Intentially redundant - This could simply be derived from the first element in the already sorted list, + // but that may not be the case in the future when custom sorting is implemented. + const globalResourcesMax = resources.reduce( + (max, r: ResourceData) => Math.max(max, r.current, r.previous), + 0, + ); + + const onStepChange = useCallback( + (activeStep: number) => { + const start = activeStep * displayAmount; + const end = start + displayAmount; + if (end > resources.length) { + setStepWindow([start, resources.length]); + } else { + setStepWindow([start, end]); + } + }, + [setStepWindow, resources, displayAmount], + ); + + const handleChartEnter = () => setActiveChart(true); + + const handleChartLeave = () => setActiveChart(false); + + const renderTooltipContent: ContentRenderer = ({ + label, + payload, + }) => { + if (!(payload && typeof label === 'string')) return [null, null]; + const items = payload.map(getTooltipItem).filter(notEmpty); + return ; + }; + + return ( + + {/* Setting fixed values for height and width generates a console warning in testing but enables ResponsiveContainer to render its children. */} + + + + + + 0, globalResourcesMax]} + tick={{ fill: styles.axis.fill }} + /> + + + + + {!disableStepper && ( + + )} + + ); +}; + +export default BarChart; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx new file mode 100644 index 0000000000..3e12c18f90 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box } from '@material-ui/core'; +import { useBarChartLabelStyles } from '../../utils/styles'; + +type BarChartLabel = { + x: number; + y: number; + height: number; + width: number; + children?: React.ReactNode; +}; + +const BarChartLabel = ({ x, y, height, width, children }: BarChartLabel) => { + const classes = useBarChartLabelStyles(); + const translateX = width * -0.5; + const childArray = React.Children.toArray(children); + + return ( + + + {childArray[0]} + {childArray.slice(1)} + + + ); +}; + +export default BarChartLabel; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx b/plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx new file mode 100644 index 0000000000..27f7a589f3 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartStepper.tsx @@ -0,0 +1,115 @@ +/* + * 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 { Paper, Slide } from '@material-ui/core'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import BarChartStepperButton from './BarChartStepperButton'; +import BarChartSteps from './BarChartSteps'; +import { useBarChartStepperStyles } from '../../utils/styles'; + +type BarChartStepperProps = { + disableScroll: boolean; + steps: number; + onChange: (activeStep: number) => void; +}; + +const BarChartStepper = ({ + steps, + disableScroll, + onChange, +}: BarChartStepperProps) => { + const classes = useBarChartStepperStyles(); + const [activeStep, setActiveStep] = useState(0); + + /* + * This calc determines how many active steps to display in the stepper. + * If the chart is displaying a large amount of resources, + * the total dots are truncated to 10. As the user clicks forward, + * eventually, there might be resources "left over" in excess of the ten dot limit. + * Once the user has reached that threshold, the difference should appear constant + * as the user clicks through the remaining resources and no extra dots should be displayed. + */ + + const diff = steps % 10; + const stepsRemaining = steps - activeStep <= diff ? diff : steps; + const displayedStep = activeStep % 10; + + useEffect(() => { + onChange(activeStep); + }, [activeStep, onChange]); + + const handleNext = () => { + setActiveStep(prevStep => prevStep + 1); + }; + + const handleBack = () => { + setActiveStep(prevStep => prevStep - 1); + }; + + const handleClick = (index: number) => { + setActiveStep(prevStep => { + const offset = index - (prevStep % 10); + return prevStep + offset; + }); + }; + + return ( + + + + + + + + + + + + + + ); +}; + +export default BarChartStepper; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx new file mode 100644 index 0000000000..b668ffaa5c --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { forwardRef, Ref } from 'react'; +import { ButtonBase, ButtonBaseProps } from '@material-ui/core'; +import { useBarChartStepperButtonStyles as useStyles } from '../../utils/styles'; + +interface BarChartStepperButtonProps extends ButtonBaseProps { + name: string; + children?: React.ReactNode; +} + +const BarChartStepperButton = forwardRef( + ( + { name, children, ...buttonBaseProps }: BarChartStepperButtonProps, + ref: Ref, + ) => { + const classes = useStyles(); + return ( + + {children} + + ); + }, +); + +export default BarChartStepperButton; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx new file mode 100644 index 0000000000..1ca0c7082a --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartSteps.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 from 'react'; +import { ButtonBase } from '@material-ui/core'; +import { useBarChartStepperStyles as useStyles } from '../../utils/styles'; + +export type BarChartSteps = { + steps: number; + activeStep: number; + onClick: (index: number) => void; +}; + +const BarChartSteps = ({ steps, activeStep, onClick }: BarChartSteps) => { + const classes = useStyles(); + const handleOnClick = (index: number) => ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + onClick(index); + }; + + return ( +
+ {[...new Array(steps)].map((_, index) => ( + +
+ + ))} +
+ ); +}; + +export default BarChartSteps; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx new file mode 100644 index 0000000000..99158c4573 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/BarChartTick.tsx @@ -0,0 +1,48 @@ +/* + * 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 BarChartLabel from './BarChartLabel'; + +type BarChartTickProps = { + x: number; + y: number; + height: number; + width: number; + payload: { + value: any; + }; + visibleTicksCount: number; +}; + +export const BarChartTick = ({ + x, + y, + height, + width, + payload, + visibleTicksCount, +}: BarChartTickProps) => { + const gutterWidth = 5; + const labelWidth = width / visibleTicksCount - gutterWidth * 2; + return ( + + {!payload.value ? 'Unlabeled' : payload.value} + + ); +}; + +export default BarChartTick; diff --git a/plugins/cost-insights/src/components/BarChart/index.ts b/plugins/cost-insights/src/components/BarChart/index.ts new file mode 100644 index 0000000000..255bd2b3d8 --- /dev/null +++ b/plugins/cost-insights/src/components/BarChart/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 { default } from './BarChart'; diff --git a/plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx b/plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx new file mode 100644 index 0000000000..c801eb86a5 --- /dev/null +++ b/plugins/cost-insights/src/components/CopyUrlToClipboard/CopyUrlToClipboard.tsx @@ -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 React, { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useCopyToClipboard } from 'react-use'; +import { Tooltip, IconButton } from '@material-ui/core'; +import AssignmentOutlinedIcon from '@material-ui/icons/AssignmentOutlined'; +import AssignmentTurnedInOutlinedIcon from '@material-ui/icons/AssignmentTurnedInOutlined'; +import SentimentVeryDissatisfiedIcon from '@material-ui/icons/SentimentVeryDissatisfied'; + +const ClipboardMessage = { + default: 'Copy URL to clipboard', + success: 'Copied!', + error: "Couldn't copy to clipboard", +}; + +const CopyUrlToClipboard = () => { + const location = useLocation(); + const [state, copyToClipboard] = useCopyToClipboard(); + const [copied, setCopied] = useState(false); + + const origin = window.location.origin; + const pathname = location.pathname; + const search = location.search; + const url = `${origin}${pathname}${search}`; + + useEffect(() => { + if (state.error) { + setCopied(false); + } else if (state.value) { + setCopied(true); + setTimeout(setCopied, 1500, false); + } + }, [state]); + + let text = ClipboardMessage.default; + let Icon = AssignmentOutlinedIcon; + + if (state.error) { + text = ClipboardMessage.error; + Icon = SentimentVeryDissatisfiedIcon; + } else if (copied) { + text = ClipboardMessage.success; + Icon = AssignmentTurnedInOutlinedIcon; + } + + return ( + + copyToClipboard(url)}> + + + + ); +}; + +export default CopyUrlToClipboard; diff --git a/plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts b/plugins/cost-insights/src/components/CopyUrlToClipboard/index.ts new file mode 100644 index 0000000000..15e2318c56 --- /dev/null +++ b/plugins/cost-insights/src/components/CopyUrlToClipboard/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 { default } from './CopyUrlToClipboard'; diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx new file mode 100644 index 0000000000..6302d9929f --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 CostGrowth from './CostGrowth'; +import { + defaultCurrencies, + Currency, + CurrencyType, + Duration, + findAlways, +} from '../../types'; +import { MockConfigProvider, MockCurrencyProvider } from '../../utils/tests'; + +const engineers = findAlways(defaultCurrencies, c => c.kind === null); +const usd = findAlways(defaultCurrencies, c => c.kind === CurrencyType.USD); +const carbon = findAlways( + defaultCurrencies, + c => c.kind === CurrencyType.CarbonOffsetTons, +); + +const MockContext = ({ + children, + currency, + engineerCost, +}: { + children: ReactNode; + currency: Currency; + engineerCost: number; +}) => ( + + + {children} + + +); + +describe.each` + engineerCost | ratio | amount | expected + ${200_000} | ${0} | ${0} | ${'Negligible'} + ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~1 ${engineers.unit}`} + ${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~1 ${engineers.unit}`} + ${200_000} | ${-0.8} | ${10_000} | ${`80% or ~1 ${engineers.unit}`} + ${200_000} | ${3} | ${600_000} | ${`300% or ~36 ${engineers.unit}s`} +`('', ({ engineerCost, ratio, amount, expected }) => { + it(`formats ${engineers.unit}s correctly for ${expected}`, async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); + +describe.each` + engineerCost | ratio | amount | expected + ${200_000} | ${0} | ${0} | ${'Negligible'} + ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${0.000000001} | ${8_334} | ${'0% or ~$8,334'} + ${200_000} | ${-0.000000001} | ${10_000} | ${'0% or ~$10,000'} + ${200_000} | ${-0.8} | ${10_000} | ${'80% or ~$10,000'} + ${200_000} | ${3} | ${600_000} | ${'300% or ~$600,000'} +`('', ({ engineerCost, ratio, amount, expected }) => { + it(`formats ${usd.unit}s correctly for ${expected}`, async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); + +describe.each` + engineerCost | ratio | amount | expected + ${200_000} | ${0} | ${0} | ${'Negligible'} + ${200_000} | ${0} | ${8_333} | ${'Negligible'} + ${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~2,381 ${carbon.unit}s`} + ${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~2,857 ${carbon.unit}s`} + ${200_000} | ${-0.8} | ${10_000} | ${`80% or ~2,857 ${carbon.unit}s`} + ${200_000} | ${3} | ${600_000} | ${`300% or ~171,429 ${carbon.unit}s`} +`('', ({ engineerCost, ratio, amount, expected }) => { + it(`formats ${carbon.unit}s correctly for ${expected}`, async () => { + const { getByText } = await renderInTestApp( + + + , + ); + expect(getByText(expected)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx new file mode 100644 index 0000000000..0c0db2d62c --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import classnames from 'classnames'; +import { + ChangeStatistic, + CurrencyType, + Duration, + EngineerThreshold, + Growth, + growthOf, + rateOf, +} from '../../types'; +import { useCostGrowthStyles as useStyles } from '../../utils/styles'; +import { formatPercent, formatCurrency } from '../../utils/formatters'; +import { indefiniteArticleOf } from '../../utils/grammar'; +import { useConfig, useCurrency } from '../../hooks'; + +export type CostGrowthProps = { + change: ChangeStatistic; + duration: Duration; +}; + +const CostGrowth = ({ change, duration }: CostGrowthProps) => { + const styles = useStyles(); + const { engineerCost } = useConfig(); + const [currency] = useCurrency(); + + // Only display costs in absolute values + const amount = Math.abs(change.amount); + const ratio = Math.abs(change.ratio); + + const rate = rateOf(engineerCost, duration); + const engineers = amount / rate; + const converted = amount / (currency.rate ?? rate); + + // Determine if growth is significant enough to highlight + const growth = growthOf(engineers, change.ratio); + const classes = classnames({ + [styles.excess]: growth === Growth.Excess, + [styles.savings]: growth === Growth.Savings, + }); + + const percent = formatPercent(ratio); + + let cost = `${percent} or ~${formatCurrency(converted, currency.unit)}`; + // Always display the converted value but use the cost in engineers + // to determine negligibility, as costs should be time-period aware + if (engineers < EngineerThreshold) { + cost = 'Negligible'; + } else if (currency.kind === CurrencyType.USD) { + cost = `${percent} or ~${currency.prefix}${formatCurrency(converted)}`; + } else if (amount < 1) { + cost = `less than ${indefiniteArticleOf(['a', 'an'], currency.unit)}`; + } + + return {cost}; +}; + +export default CostGrowth; diff --git a/plugins/cost-insights/src/components/CostGrowth/index.ts b/plugins/cost-insights/src/components/CostGrowth/index.ts new file mode 100644 index 0000000000..3cd809ee49 --- /dev/null +++ b/plugins/cost-insights/src/components/CostGrowth/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 { default } from './CostGrowth'; diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx new file mode 100644 index 0000000000..8af5091188 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.test.tsx @@ -0,0 +1,95 @@ +/* + * 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 CostInsightsHeader from './CostInsightsHeader'; +import { renderInTestApp } from '@backstage/test-utils'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, +} from '@backstage/core'; +import React from 'react'; + +describe('', () => { + const identityApi: Partial = { + getProfile: () => ({ + email: 'test-email@example.com', + displayName: 'User 1', + }), + }; + + const apis = ApiRegistry.from([[identityApiRef, identityApi]]); + + it('Shows nothing to do when no alerts exist', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.queryByText(/doing great/)).toBeInTheDocument(); + }); + + it('Shows work to do when alerts > 1', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.queryByText(/few things/)).toBeInTheDocument(); + }); + + it('Handles grammar with a single alert', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.queryByText(/things/)).not.toBeInTheDocument(); + expect(rendered.queryByText(/one thing/)).toBeInTheDocument(); + }); + + it('Shows no costs when hasCostData is false', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.queryByText(/this is awkward/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx new file mode 100644 index 0000000000..57fa26b5fc --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx @@ -0,0 +1,136 @@ +/* + * 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 { Typography } from '@material-ui/core'; +import { identityApiRef, ProfileInfo, useApi } from '@backstage/core'; +import { useCostInsightsStyles } from '../../utils/styles'; +import { Group } from '../../types'; + +function name(profile: ProfileInfo | undefined): string { + return profile?.displayName || 'Mysterious Stranger'; +} + +type CostInsightsHeaderProps = { + owner: string; + groups: Group[]; + hasCostData: boolean; + alerts: number; +}; + +const CostInsightsHeader = (props: CostInsightsHeaderProps) => { + if (!props.hasCostData) { + return ; + } + if (props.alerts) { + return ; + } + return ; +}; + +const CostInsightsHeaderNoData = ({ + owner, + groups, +}: CostInsightsHeaderProps) => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + const hasMultipleGroups = groups.length > 1; + + return ( + <> + + + đŸ˜ŗ + {' '} + Well this is awkward + + + Hey, {name(profile)}! {owner} doesn't seem to have any + cloud costs. + + {hasMultipleGroups && ( + + Maybe we picked the wrong team, choose another from the menu above? + + )} + + ); +}; + +const CostInsightsHeaderAlerts = ({ + owner, + alerts, +}: CostInsightsHeaderProps) => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + + return ( + <> + + + 🔎 + {' '} + You have {alerts} thing{alerts > 1 && 's'} to look into + + + Hey, {name(profile)}! We've identified{' '} + {alerts > 1 ? 'a few things ' : 'one thing '} + {owner} should look into next. + + + ); +}; + +const CostInsightsHeaderNoAlerts = ({ owner }: CostInsightsHeaderProps) => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + + return ( + <> + + + 👍 + {' '} + Your team is doing great + + + Hey, {name(profile)}! {owner} is doing well. No major + changes this month. + + + ); +}; + +export const CostInsightsHeaderNoGroups = () => { + const profile = useApi(identityApiRef).getProfile(); + const classes = useCostInsightsStyles(); + return ( + <> + + + đŸ˜ŗ + {' '} + Well this is awkward + + + Hey, {name(profile)}! It doesn't look like you belong to any + teams. + + + ); +}; + +export default CostInsightsHeader; diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/index.ts b/plugins/cost-insights/src/components/CostInsightsHeader/index.ts new file mode 100644 index 0000000000..8e7ddcc9c8 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsHeader/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 { default, CostInsightsHeaderNoGroups } from './CostInsightsHeader'; diff --git a/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx new file mode 100644 index 0000000000..61bf7ac22f --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.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 { makeStyles } from '@material-ui/core'; +import { Header, Page, pageTheme } from '@backstage/core'; +import { Group } from '../../types'; +import CostInsightsTabs from '../CostInsightsTabs'; + +const useStyles = makeStyles(theme => ({ + root: { + gridArea: 'pageContent', + }, + header: { + boxShadow: 'none', + }, + content: { + padding: theme.spacing(4), + }, +})); + +type CostInsightsLayoutProps = { + title?: string; + groups: Group[]; + children?: React.ReactNode; +}; + +const CostInsightsLayout = ({ groups, children }: CostInsightsLayoutProps) => { + const classes = useStyles(); + return ( + +
+
+ +
{children}
+
+ + ); +}; + +export default CostInsightsLayout; diff --git a/plugins/cost-insights/src/components/CostInsightsLayout/index.ts b/plugins/cost-insights/src/components/CostInsightsLayout/index.ts new file mode 100644 index 0000000000..dd35eb2855 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsLayout/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 { default } from './CostInsightsLayout'; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.test.tsx new file mode 100644 index 0000000000..3ec2828029 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.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 React from 'react'; +import { default as HappyFace } from '@material-ui/icons/SentimentSatisfiedAlt'; +import { renderInTestApp } from '@backstage/test-utils'; +import CostInsightsNavigation from './CostInsightsNavigation'; +import { defaultCurrencies, Metric, Product, Icon } from '../../types'; +import { MockConfigProvider, MockScrollProvider } from '../../utils/tests'; +import { getDefaultNavigationItems } from '../../utils/navigation'; + +const mockIcons: Icon[] = [ + { + kind: 'some-product', + component: , + }, +]; + +const mockProducts: Product[] = [ + { + kind: 'some-product', + name: 'Some Product', + }, +]; + +const mockMetrics: Metric[] = [ + { + kind: 'some-metric', + name: 'Some Metric', + }, +]; + +const renderWrapped = (children: React.ReactNode) => + renderInTestApp( + + {children} + , + ); + +describe('', () => { + it('should render each navigation item', async () => { + const { getByText } = await renderWrapped( + , + ); + getDefaultNavigationItems(3) + .map(item => item.title) + .concat(mockProducts.map(p => p.name)) + .forEach(name => expect(getByText(name)).toBeInTheDocument()); + }); + + it('should not display action items navigation if there are no action items', async () => { + const rendered = await renderWrapped(); + expect(rendered.queryByText(/Action Items/)).not.toBeInTheDocument(); + }); + + it('should display the correct amount of action items in the badge', async () => { + const rendered = await renderWrapped(); + expect(rendered.getByText(/3/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx new file mode 100644 index 0000000000..fdbabe0402 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx @@ -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 React from 'react'; +import { + MenuList, + MenuItem, + ListItemIcon, + ListItemText, + Typography, + Badge, +} from '@material-ui/core'; +import { useNavigationStyles } from '../../utils/styles'; +import { useConfig, useScroll } from '../../hooks'; +import { findAlways } from '../../types'; +import { + DefaultNavigation, + NavigationItem, + getDefaultNavigationItems, +} from '../../utils/navigation'; + +type CostInsightsNavigationProps = { + alerts: number; +}; + +const CostInsightsNavigation = ({ alerts }: CostInsightsNavigationProps) => { + const classes = useNavigationStyles(); + const { products, icons } = useConfig(); + + const productNavigationItems: NavigationItem[] = products.map(product => ({ + navigation: product.kind, + icon: findAlways(icons, i => i.kind === product.kind).component, + title: product.name, + })); + + const navigationItems = getDefaultNavigationItems(alerts).concat( + productNavigationItems, + ); + + return ( + + {navigationItems.map((item: NavigationItem) => ( + + {React.cloneElement(item.icon, { + className: classes.navigationIcon, + })} + + ) : ( + React.cloneElement(item.icon, { + className: classes.navigationIcon, + }) + ) + } + title={item.title} + /> + ))} + + ); +}; + +const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => { + const classes = useNavigationStyles(); + const { scrollIntoView } = useScroll(navigation); + return ( + + {icon} + {title}} + /> + + ); +}; + +export default CostInsightsNavigation; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/index.ts b/plugins/cost-insights/src/components/CostInsightsNavigation/index.ts new file mode 100644 index 0000000000..6c32ba7677 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/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 { default } from './CostInsightsNavigation'; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx new file mode 100644 index 0000000000..c426eff8bc --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -0,0 +1,249 @@ +/* + * 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, useEffect, useState } from 'react'; +import { Box, Container, Divider, Grid } from '@material-ui/core'; +import { Progress, useApi, featureFlagsApiRef } from '@backstage/core'; +import { default as MaterialAlert } from '@material-ui/lab/Alert'; +import { costInsightsApiRef } from '../../api'; +import AlertActionCardList from '../AlertActionCardList'; +import AlertInsights from '../AlertInsights'; +import CostInsightsLayout from '../CostInsightsLayout'; +import CopyUrlToClipboard from '../CopyUrlToClipboard'; +import CurrencySelect from '../CurrencySelect'; +import WhyCostsMatter from '../WhyCostsMatter'; +import CostInsightsHeader, { + CostInsightsHeaderNoGroups, +} from '../CostInsightsHeader'; +import CostInsightsNavigation from '../CostInsightsNavigation'; +import CostOverviewCard from '../CostOverviewCard'; +import ProductInsights from '../ProductInsights'; +import CostInsightsSupportButton from '../CostInsightsSupportButton'; +import { + useLoading, + useFilters, + useGroups, + useCurrency, + useConfig, +} from '../../hooks'; +import { Alert, Cost, intervalsOf, Maybe, Project } from '../../types'; +import { mapLoadingToProps } from './selector'; + +const CostInsightsPage = () => { + const flags = useApi(featureFlagsApiRef).getFlags(); + // There is not currently a UI to set feature flags + // flags.set('cost-insights-currencies', FeatureFlagState.On); + const client = useApi(costInsightsApiRef); + const { currencies } = useConfig(); + const groups = useGroups(); + const [currency, setCurrency] = useCurrency(); + const [projects, setProjects] = useState>(null); + const [dailyCost, setDailyCost] = useState>(null); + const [alerts, setAlerts] = useState>(null); + const [error, setError] = useState>(null); + + const { pageFilters } = useFilters(p => p); + const { + loadingActions, + loadingGroups, + loadingInitial, + dispatchInitial, + dispatchInsights, + dispatchNone, + } = useLoading(mapLoadingToProps); + + /* eslint-disable react-hooks/exhaustive-deps */ + // The dispatchLoading functions are derived from loading state using mapLoadingToProps, to + // provide nicer props for the component. These are re-derived whenever loading state changes, + // which causes an infinite loop as product panels load and re-trigger the useEffect below. + // Since the functions don't change, we can memoize - but we trigger the same loop if we satisfy + // exhaustive-deps by including the function itself in dependencies. + + const dispatchLoadingInitial = useCallback(dispatchInitial, []); + const dispatchLoadingInsights = useCallback(dispatchInsights, []); + const dispatchLoadingNone = useCallback(dispatchNone, []); + /* eslint-enable react-hooks/exhaustive-deps */ + + useEffect(() => { + async function getInsights() { + setError(null); + try { + if (pageFilters.group) { + dispatchLoadingInsights(true); + const [ + fetchedProjects, + fetchedCosts, + fetchedAlerts, + ] = await Promise.all([ + client.getGroupProjects(pageFilters.group), + pageFilters.project + ? client.getProjectDailyCost( + pageFilters.project, + pageFilters.metric, + intervalsOf(pageFilters.duration), + ) + : client.getGroupDailyCost( + pageFilters.group, + pageFilters.metric, + intervalsOf(pageFilters.duration), + ), + client.getAlerts(pageFilters.group), + ]); + setProjects(fetchedProjects); + setDailyCost(fetchedCosts); + setAlerts(fetchedAlerts); + } else { + dispatchLoadingNone(loadingActions); + } + } catch (e) { + setError(e); + dispatchLoadingNone(loadingActions); + } finally { + dispatchLoadingInitial(false); + dispatchLoadingInsights(false); + } + } + + // Wait for user groups to finish loading + if (!loadingGroups) { + getInsights(); + } + }, [ + client, + pageFilters, + loadingGroups, + dispatchLoadingInsights, + dispatchLoadingInitial, + dispatchLoadingNone, + loadingActions, + ]); + + if (loadingInitial) { + return ; + } + + if (error) { + return {error.message}; + } + + // Loaded but no groups found for the user + if (!pageFilters.group) { + return ( + + + + + + + + + + + + + + ); + } + + // These should be defined, alerts can be an empty array but that's truthy + if (!dailyCost || !alerts) { + return ( + {`Error: Could not fetch cost insights data for team ${pageFilters.group}`} + ); + } + + return ( + + + + + + + + + + {!!flags.get('cost-insights-currencies') && ( + + + + )} + + + + + + + + + {!!alerts.length && ( + <> + + + + + + + + )} + + + {!!dailyCost.aggregation.length && ( + + )} + + + + + {!!alerts?.length && ( + + + + )} + + {!alerts.length && } + + + + + + + + + + + ); +}; + +export default CostInsightsPage; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx new file mode 100644 index 0000000000..b2276481b2 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.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 React from 'react'; +import CostInsightsPage from './CostInsightsPage'; +import { FilterProvider } from '../../hooks/useFilters'; +import { LoadingProvider } from '../../hooks/useLoading'; +import { GroupsProvider } from '../../hooks/useGroups'; +import { CurrencyProvider } from '../../hooks/useCurrency'; +import { ScrollProvider } from '../../hooks/useScroll'; +import { ConfigProvider } from '../../hooks/useConfig'; +import { CostInsightsThemeProvider } from './CostInsightsThemeProvider'; + +const CostInsightsPageRoot = () => ( + + + + + + + + + + + + + + + +); + +export default CostInsightsPageRoot; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx new file mode 100644 index 0000000000..cecb076d35 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.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 { createMuiTheme, ThemeProvider } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { + costInsightsDarkTheme, + costInsightsLightTheme, +} from '../../utils/styles'; +import { CostInsightsTheme } from '../../types'; + +interface CostInsightsThemeProviderProps { + children?: React.ReactNode; +} + +export const CostInsightsThemeProvider = ({ + children, +}: CostInsightsThemeProviderProps) => { + return ( + + createMuiTheme({ + ...theme, + palette: { + ...theme.palette, + ...(theme.palette.type === 'dark' + ? costInsightsDarkTheme.palette + : costInsightsLightTheme.palette), + }, + }) as CostInsightsTheme + } + > + {children} + + ); +}; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/index.ts b/plugins/cost-insights/src/components/CostInsightsPage/index.ts new file mode 100644 index 0000000000..67d5eb5daa --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/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 { default } from './CostInsightsPageRoot'; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx new file mode 100644 index 0000000000..51db47c082 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx @@ -0,0 +1,42 @@ +/* + * 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 { MapLoadingToProps } from '../../hooks'; +import { getResetState, DefaultLoadingAction } from '../../types'; + +type CostInsightsPageLoadingProps = { + loadingActions: Array; + loadingGroups: boolean; + loadingInitial: boolean; + dispatchInitial: (isLoading: boolean) => void; + dispatchInsights: (isLoading: boolean) => void; + dispatchNone: (loadingActions: string[]) => void; +}; + +export const mapLoadingToProps: MapLoadingToProps = ({ + state, + actions, + dispatch, +}) => ({ + loadingActions: actions, + loadingGroups: state[DefaultLoadingAction.UserGroups], + loadingInitial: state[DefaultLoadingAction.CostInsightsInitial], + dispatchInitial: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsInitial]: isLoading }), + dispatchInsights: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsPage]: isLoading }), + dispatchNone: (loadingActions: string[]) => + dispatch(getResetState(loadingActions)), +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.tsx b/plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx similarity index 61% rename from plugins/techdocs/src/reader/components/TechDocsPageWrapper.tsx rename to plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx index b511fde1e7..c8e7fdac97 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.tsx +++ b/plugins/cost-insights/src/components/CostInsightsSupportButton/CostInsightsSupportButton.tsx @@ -15,23 +15,14 @@ */ import React from 'react'; -import { Header, Content, Page, pageTheme } from '@backstage/core'; +import { SupportButton } from '@backstage/core'; -type TechDocsPageWrapperProps = { - title: string; - subtitle: string; - children: any; -}; - -export const TechDocsPageWrapper = ({ - children, - title, - subtitle, -}: TechDocsPageWrapperProps) => { +const CostInsightsSupportButton = () => { return ( - -
- {children} - + + Insights into cloud costs for your organization + ); }; + +export default CostInsightsSupportButton; diff --git a/plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts b/plugins/cost-insights/src/components/CostInsightsSupportButton/index.ts new file mode 100644 index 0000000000..eab947c1be --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsSupportButton/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 { default } from './CostInsightsSupportButton'; diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx new file mode 100644 index 0000000000..0859ad18ce --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.test.tsx @@ -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 React from 'react'; +import CostInsightsTabs from './CostInsightsTabs'; +import UserEvent from '@testing-library/user-event'; +import { Group, defaultCurrencies } from '../../types'; +import { MockFilterProvider, MockConfigProvider } from '../../utils/tests'; +import { LoadingContext } from '../../hooks/useLoading'; +import { renderInTestApp } from '@backstage/test-utils'; +import { mockDefaultState } from '../../utils/mockData'; + +const mockSetPageFilters = jest.fn(); +const mockLoadingDispatch = jest.fn(); + +const mockGroups: Group[] = [ + { + id: 'test-group-1', + }, + { + id: 'test-group-2', + }, + { + id: 'test-group-3', + }, +]; + +describe('', () => { + const renderWrapped = (children: React.ReactNode) => + renderInTestApp( + + + + {children} + + + , + ); + + it('Does NOT display the tabs bar if owner belongs to less than two GROUPS', async () => { + const oneGroup: Group[] = [{ id: 'test-group-1' }]; + const rendered = await renderWrapped( + , + ); + expect( + rendered.container.querySelector('.cost-insights-tabs'), + ).not.toBeInTheDocument(); + }); + + it('Displays the correct number of groups in the groups tab', async () => { + const rendered = await renderWrapped( + , + ); + expect(rendered.getByText('3 teams')).toBeInTheDocument(); + }); + + it('Applies the correct group filter when selected', async () => { + const selectedGroup = expect.objectContaining({ group: 'test-group-1' }); + const rendered = await renderWrapped( + , + ); + UserEvent.click(rendered.getByTestId('cost-insights-groups-tab')); + UserEvent.click(rendered.getByTestId('test-group-1')); + expect(mockSetPageFilters).toHaveBeenCalledWith(selectedGroup); + }); + + it('Displays the correct group names in the menu', async () => { + const rendered = await renderWrapped( + , + ); + UserEvent.click(rendered.getByTestId('cost-insights-groups-tab')); + mockGroups.forEach(group => + expect(rendered.getByText(group.id)).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx new file mode 100644 index 0000000000..3b86aa79ef --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/CostInsightsTabs.tsx @@ -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 React, { useState } from 'react'; +import { Menu, MenuItem, Tab, Tabs, Typography } from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { mapLoadingToProps, mapFiltersToProps } from './selector'; +import { Group } from '../../types'; +import { useFilters, useLoading } from '../../hooks'; +import { useCostInsightsTabsStyles as useStyles } from '../../utils/styles'; + +export type CostInsightsTabsProps = { + groups: Group[]; +}; + +const CostInsightsTabs = ({ groups }: CostInsightsTabsProps) => { + const classes = useStyles(); + const [index] = useState(0); // index is fixed for now until other tabs are added + const [groupMenuEl, setGroupMenuEl] = useState(null); + const { group, setGroup } = useFilters(mapFiltersToProps); + const { loadingActions, dispatchReset } = useLoading(mapLoadingToProps); + + const openGroupMenu = (e: any) => setGroupMenuEl(e.currentTarget as Element); + + const closeGroupMenu = () => setGroupMenuEl(null); + + const updateGroupFilterAndCloseMenu = (g: Group) => () => { + dispatchReset(loadingActions); + closeGroupMenu(); + setGroup(g); + }; + + const renderTabLabel = () => ( +
+ + {`${groups.length} teams`} + + +
+ ); + + const hasAtLeastTwoGroups = groups.length >= 2; + + if (!hasAtLeastTwoGroups) return null; + + return ( + <> + + + + + {groups.map((g: Group) => ( + + {g.id} + + ))} + + + ); +}; + +export default CostInsightsTabs; diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/index.ts b/plugins/cost-insights/src/components/CostInsightsTabs/index.ts new file mode 100644 index 0000000000..7055334c2d --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/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 { default } from './CostInsightsTabs'; diff --git a/plugins/cost-insights/src/components/CostInsightsTabs/selector.ts b/plugins/cost-insights/src/components/CostInsightsTabs/selector.ts new file mode 100644 index 0000000000..3aa53cce21 --- /dev/null +++ b/plugins/cost-insights/src/components/CostInsightsTabs/selector.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 { MapFiltersToProps } from '../../hooks/useFilters'; +import { MapLoadingToProps } from '../../hooks/useLoading'; +import { Group, PageFilters, getResetStateWithoutInitial } from '../../types'; + +type CostInsightsTabsFilterProps = PageFilters & { + setGroup: (group: Group) => void; +}; + +type CostInsightsTabsLoadingProps = { + loadingActions: Array; + dispatchReset: (loadingActions: string[]) => void; +}; + +export const mapFiltersToProps: MapFiltersToProps = ({ + pageFilters, + setPageFilters, +}) => ({ + ...pageFilters, + setGroup: (group: Group) => + setPageFilters({ + ...pageFilters, + group: group.id, + project: null, + }), +}); + +export const mapLoadingToProps: MapLoadingToProps = ({ + actions, + dispatch, +}) => ({ + loadingActions: actions, + dispatchReset: (loadingActions: string[]) => + dispatch(getResetStateWithoutInitial(loadingActions)), +}); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx new file mode 100644 index 0000000000..ef174132a3 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Card, CardContent, Divider } from '@material-ui/core'; +import CostOverviewChart from '../CostOverviewChart'; +import CostOverviewChartLegend from '../CostOverviewChartLegend'; +import CostOverviewHeader from './CostOverviewHeader'; +import CostOverviewFooter from './CostOverviewFooter'; +import MetricSelect from '../MetricSelect'; +import PeriodSelect from '../PeriodSelect'; +import ProjectSelect from '../ProjectSelect'; +import { useScroll, useFilters, useConfig } from '../../hooks'; +import { mapFiltersToProps } from './selector'; +import { DefaultNavigation } from '../../utils/navigation'; +import { + ChangeStatistic, + DateAggregation, + Project, + Trendline, + findAlways, +} from '../../types'; + +type CostOverviewCardProps = { + change: ChangeStatistic; + aggregation: Array; + trendline: Trendline; + projects: Array; +}; + +const CostOverviewCard = ({ + change, + aggregation, + trendline, + projects, +}: CostOverviewCardProps) => { + const { metrics } = useConfig(); + const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); + const { setDuration, setProject, metric, setMetric, ...filters } = useFilters( + mapFiltersToProps, + ); + + const { name } = findAlways(metrics, m => m.kind === metric); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default CostOverviewCard; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx new file mode 100644 index 0000000000..03920887b8 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewFooter.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Box } from '@material-ui/core'; + +type CostOverviewFooterProps = { + children?: React.ReactNode; +}; + +const CostOverviewFooter = ({ children }: CostOverviewFooterProps) => ( + + {React.Children.map(children, child => ( + {child} + ))} + +); + +export default CostOverviewFooter; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx new file mode 100644 index 0000000000..5de90d155a --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx @@ -0,0 +1,53 @@ +/* + * 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'; + +type CostOverviewHeaderProps = { + title: string; + subtitle?: string; + children?: React.ReactNode; +}; + +const CostOverviewHeader = ({ + title, + subtitle, + children, +}: CostOverviewHeaderProps) => ( + + + + {title} + + {!!subtitle && ( + + {subtitle} + + )} + + + {children} + + +); + +export default CostOverviewHeader; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/index.ts b/plugins/cost-insights/src/components/CostOverviewCard/index.ts new file mode 100644 index 0000000000..c59fb5cc5b --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/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 { default } from './CostOverviewCard'; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx b/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx new file mode 100644 index 0000000000..ef65f38dbb --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/selector.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Duration, Maybe, PageFilters } from '../../types'; +import { MapFiltersToProps } from '../../hooks/useFilters'; + +type CostOverviewFilterProps = PageFilters & { + setDuration: (duration: Duration) => void; + setProject: (project: Maybe) => void; + setMetric: (metric: Maybe) => void; +}; + +export const mapFiltersToProps: MapFiltersToProps = ({ + pageFilters, + setPageFilters, +}) => ({ + ...pageFilters, + project: pageFilters.project || 'all', + setDuration: (duration: Duration) => + setPageFilters({ + ...pageFilters, + duration, + }), + setProject: (project: Maybe) => + setPageFilters({ + ...pageFilters, + project: project === 'all' ? null : project, + }), + setMetric: (metric: Maybe) => + setPageFilters({ + ...pageFilters, + metric: metric, + }), +}); diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.tsx new file mode 100644 index 0000000000..e6311d147a --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.test.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 CostOverviewChart from './CostOverviewChart'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { DateAggregation, Trendline } from '../../types'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const mockAggregation = [ + { date: '2020-04-01', amount: 100 }, + { date: '2020-04-02', amount: 101 }, + { date: '2020-04-03', amount: 102 }, + { date: '2020-04-04', amount: 103 }, +] as Array; + +const mockTrendline = { slope: 0.3, intercept: 101.5 } as Trendline; +const mockMetric = 'mock-metric'; + +describe('', () => { + it('Renders without exploding', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect( + rendered.container.querySelector('.cost-overview-chart'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx new file mode 100644 index 0000000000..2b40668409 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewChart.tsx @@ -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 React from 'react'; +import { + ComposedChart, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Area, + Line, + ResponsiveContainer, +} from 'recharts'; +import { + Maybe, + DateAggregation, + Trendline, + CostInsightsTheme, +} from '../../types'; +import { + overviewGraphTickFormatter, + formatGraphValue, +} from '../../utils/graphs'; +import CostOverviewTooltip from './CostOverviewTooltip'; +import { useTheme } from '@material-ui/core'; +import { useCostOverviewStyles as useStyles } from '../../utils/styles'; +import { NULL_METRIC } from '../../hooks/useConfig'; + +type CostOverviewChartProps = { + responsive: boolean; + aggregation: Array; + trendline?: Maybe; + metric: string | null; + tooltip: string; +}; + +const CostOverviewChart = ({ + responsive = true, + aggregation, + trendline, + metric, + tooltip, +}: CostOverviewChartProps) => { + const theme = useTheme(); + const styles = useStyles(theme); + + const id = metric ? metric : NULL_METRIC; + + const dailyCostData = aggregation.map((entry: DateAggregation) => ({ + date: Date.parse(entry.date), + [id]: entry.amount, + trend: trendline + ? trendline.slope * (Date.parse(entry.date) / 1000) + trendline.intercept + : null, + })); + + return ( + + + + + 0, 'dataMax']} + tick={{ fill: styles.axis.fill }} + tickFormatter={formatGraphValue} + width={styles.yAxis.width} + yAxisId={id} + /> + + + } + animationDuration={100} + /> + + + ); +}; + +export default CostOverviewChart; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.tsx new file mode 100644 index 0000000000..17d466441d --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/CostOverviewTooltip.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 React from 'react'; +import moment from 'moment'; +import { TooltipPayload, TooltipProps } from 'recharts'; +import Tooltip from '../../components/Tooltip'; +import { DEFAULT_DATE_FORMAT } from '../../types'; +import { formatGraphValue } from '../../utils/graphs'; + +type CostOverviewTooltipProps = TooltipProps & { + metric: string; + name: string; +}; + +const CostOverviewTooltip = ({ + label, + payload, + metric, + name, +}: CostOverviewTooltipProps) => { + const tooltipLabel = moment(label).format(DEFAULT_DATE_FORMAT); + const items = payload + ?.filter(data => data.name === metric) + .map((data: TooltipPayload) => ({ + label: name, + value: formatGraphValue(data.value as number), + fill: data.fill as string, + })); + return ; +}; + +export default CostOverviewTooltip; diff --git a/plugins/cost-insights/src/components/CostOverviewChart/index.ts b/plugins/cost-insights/src/components/CostOverviewChart/index.ts new file mode 100644 index 0000000000..21f345a282 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChart/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 { default } from './CostOverviewChart'; diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx new file mode 100644 index 0000000000..a40ff58ec1 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.test.tsx @@ -0,0 +1,40 @@ +/* + * 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 { renderInTestApp } from '@backstage/test-utils'; +import CostOverviewChartLegend from './CostOverviewChartLegend'; +import React from 'react'; +import { ChangeStatistic } from '../../types'; + +describe('', () => { + it('Correctly displays text if change is not supplied', async () => { + const rendered = await renderInTestApp( + , + ); + expect(rendered.queryByText('Unclear')).toBeInTheDocument(); + }); + it('Correctly displays formatted change percentage', async () => { + const change = { + ratio: 0.3456, + amount: 40000, + } as ChangeStatistic; + const rendered = await renderInTestApp( + , + ); + expect(rendered.queryByText('Unclear')).not.toBeInTheDocument(); + expect(rendered.queryByText('35%')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx new file mode 100644 index 0000000000..538cd2a63a --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChartLegend/CostOverviewChartLegend.tsx @@ -0,0 +1,48 @@ +/* + * 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 { formatPercent } from '../../utils/formatters'; +import { ChangeStatistic, CostInsightsTheme } from '../../types'; + +type CostOverviewChartLegendProps = { + change?: ChangeStatistic; + title: string; + tooltip?: string; +}; + +const CostOverviewChartLegend = ({ + change, + title, + tooltip, +}: CostOverviewChartLegendProps) => { + const theme = useTheme(); + + return ( + + + {change ? formatPercent(change.ratio) : 'Unclear'} + + + ); +}; + +export default CostOverviewChartLegend; diff --git a/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts b/plugins/cost-insights/src/components/CostOverviewChartLegend/index.ts new file mode 100644 index 0000000000..1b6c1fe975 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewChartLegend/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 { default } from './CostOverviewChartLegend'; diff --git a/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx new file mode 100644 index 0000000000..24063a664e --- /dev/null +++ b/plugins/cost-insights/src/components/CurrencySelect/CurrencySelect.tsx @@ -0,0 +1,75 @@ +/* + * 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 { MenuItem, Select, SelectProps } from '@material-ui/core'; +import { Currency, CurrencyType, findAlways } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; + +const NULL_VALUE = 'engineers'; + +type CurrencySelectProps = { + currency: Currency; + currencies: Currency[]; + onSelect: (currency: Currency) => void; +}; + +const CurrencySelect = ({ + currency, + currencies, + onSelect, +}: CurrencySelectProps) => { + const classes = useStyles(); + + const getOption = (value: unknown) => { + const kind = (value === NULL_VALUE ? null : value) as CurrencyType; + return findAlways(currencies, c => c.kind === kind); + }; + + const handleOnChange: SelectProps['onChange'] = e => { + const option = getOption(e.target.value); + onSelect(option); + }; + + const renderValue: SelectProps['renderValue'] = value => { + const option = getOption(value); + return {option.label}; + }; + + return ( + + ); +}; + +export default CurrencySelect; diff --git a/plugins/cost-insights/src/components/CurrencySelect/index.ts b/plugins/cost-insights/src/components/CurrencySelect/index.ts new file mode 100644 index 0000000000..322b67d4ce --- /dev/null +++ b/plugins/cost-insights/src/components/CurrencySelect/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 { default } from './CurrencySelect'; diff --git a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx new file mode 100644 index 0000000000..9783fadcb2 --- /dev/null +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/LabelDataflowInstructionsPage.tsx @@ -0,0 +1,96 @@ +/* + * 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 { CodeSnippet } from '@backstage/core'; +import AlertInstructionsLayout from '../AlertInstructionsLayout'; + +const LabelDataflowInstructionsPage = () => { + return ( + + Labeling Dataflow Jobs + + Labels in Google Cloud Platform are key-value pairs that can be added to + most types of cloud resources. Since these labels are also exported in + billing data, adding labels allows a granular breakdown of cloud cost by + software entity. + + + In Cloud Dataflow, labels can be added to a job either programmatically + or via the command-line when launching a job. Note that GCP has + + restrictions + {' '} + on the length and characters that can be used in labels. + + + Labels are not retroactive, so cost tracking is only possible from when + the labels are first added to a Dataflow job. + + + + DataflowPipelineOptions + + Dataflow jobs using Beam's{' '} + + DataflowPipelineOptions + {' '} + directly can use the setLabels function to add one or more + labels: + + + + Dataflow jobs using Scio can similarly set options on the ScioContext: + "my-dataflow-job").asJava)`} + /> + + + + + Command-line + + Dataflow jobs launched from the command-line can add labels as an + argument: + + + + For more information on specifying options, see the{' '} + + Dataflow documentation + {' '} + or{' '} + + Scio Scaladoc + + . + + + + ); +}; + +export default LabelDataflowInstructionsPage; diff --git a/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/index.ts new file mode 100644 index 0000000000..052ed379ec --- /dev/null +++ b/plugins/cost-insights/src/components/LabelDataflowInstructionsPage/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 { default } from './LabelDataflowInstructionsPage'; diff --git a/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx b/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx new file mode 100644 index 0000000000..dd87ecc8a1 --- /dev/null +++ b/plugins/cost-insights/src/components/LegendItem/LegendItem.tsx @@ -0,0 +1,78 @@ +/* + * 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, 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 = { + title: string; + tooltipText?: string; + markerColor?: string; + children?: React.ReactNode; +}; + +const LegendItem = ({ + title, + tooltipText, + markerColor, + children, +}: LegendItemProps) => { + const classes = useCostGrowthLegendStyles(); + return ( + + + {markerColor && ( +
+ +
+ )} + + {title} + + {tooltipText && ( + + {tooltipText} + + } + placement="top-start" + > + + + + + )} +
+ + + {children} + + +
+ ); +}; + +export default LegendItem; diff --git a/plugins/cost-insights/src/components/LegendItem/index.ts b/plugins/cost-insights/src/components/LegendItem/index.ts new file mode 100644 index 0000000000..763b8fc4f4 --- /dev/null +++ b/plugins/cost-insights/src/components/LegendItem/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 { default } from './LegendItem'; diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx new file mode 100644 index 0000000000..67d1ec3ad3 --- /dev/null +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.test.tsx @@ -0,0 +1,71 @@ +/* + * 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 { waitFor } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; +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' }], + onSelect: jest.fn(), + }; + const { getByText } = await renderInTestApp( + , + ); + 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' }, + ], + onSelect: jest.fn(), + }; + const { getAllByText, getByText, getByRole } = await renderInTestApp( + , + ); + const button = getByRole('button'); + + UserEvent.click(button); + + await waitFor(() => getAllByText(/billie-nullish/)); + + // 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(); + }); +}); diff --git a/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx new file mode 100644 index 0000000000..1d3af911d4 --- /dev/null +++ b/plugins/cost-insights/src/components/MetricSelect/MetricSelect.tsx @@ -0,0 +1,67 @@ +/* + * 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 { Select, MenuItem } from '@material-ui/core'; +import { Maybe, Metric, findAlways } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; +import { NULL_METRIC } from '../../hooks/useConfig'; + +export type MetricSelectProps = { + metric: Maybe; + metrics: Array; + onSelect: (metric: Maybe) => void; +}; + +const MetricSelect = ({ metric, metrics, onSelect }: MetricSelectProps) => { + const classes = useStyles(); + + const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { + if (e.target.value === NULL_METRIC) { + 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 ( + + ); +}; + +export default MetricSelect; diff --git a/plugins/cost-insights/src/components/MetricSelect/index.ts b/plugins/cost-insights/src/components/MetricSelect/index.ts new file mode 100644 index 0000000000..f68e5031c1 --- /dev/null +++ b/plugins/cost-insights/src/components/MetricSelect/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 { default } from './MetricSelect'; diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx new file mode 100644 index 0000000000..c632ebad34 --- /dev/null +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { 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'; + +const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]); + +Date.now = jest.fn(() => new Date(Date.parse('2020-05-01')).valueOf()); + +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'); + UserEvent.click(button); + await waitFor(() => rendered.getByText('Past 60 Days')); + options.forEach(option => + expect( + rendered.getByTestId(`period-select-option-${option.value}`), + ).toBeInTheDocument(), + ); + }); + + describe.each` + duration + ${Duration.P1M} + ${Duration.P3M} + ${Duration.P90D} + ${Duration.P30D} + `('Should select the correct duration', ({ duration }) => { + it(`Should select ${duration}`, async () => { + const mockOnSelect = jest.fn(); + const mockAggregation = + DefaultPageFilters.duration === duration + ? Duration.P1M + : DefaultPageFilters.duration; + + const rendered = await renderInTestApp( + , + ); + const periodSelect = rendered.getByTestId('period-select'); + const button = getByRole(periodSelect, 'button'); + + UserEvent.click(button); + await waitFor(() => rendered.getByText('Past 60 Days')); + UserEvent.click(rendered.getByTestId(`period-select-option-${duration}`)); + expect(mockOnSelect).toHaveBeenLastCalledWith(duration); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx new file mode 100644 index 0000000000..688e22452e --- /dev/null +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -0,0 +1,100 @@ +/* + * 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 { MenuItem, Select, SelectProps } from '@material-ui/core'; +import { + formatLastTwoLookaheadQuarters, + formatLastTwoMonths, +} from '../../utils/formatters'; +import { Duration, findAlways } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; + +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, + }, +]; + +type PeriodSelectProps = { + duration: Duration; + onSelect: (duration: Duration) => void; + options?: PeriodOption[]; +}; + +const PeriodSelect = ({ + duration, + onSelect, + options = DEFAULT_OPTIONS, +}: PeriodSelectProps) => { + const classes = useStyles(); + + 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); + return {option.label}; + }; + + return ( + + ); +}; + +export default PeriodSelect; diff --git a/plugins/cost-insights/src/components/PeriodSelect/index.ts b/plugins/cost-insights/src/components/PeriodSelect/index.ts new file mode 100644 index 0000000000..7bd70b3358 --- /dev/null +++ b/plugins/cost-insights/src/components/PeriodSelect/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 { default } from './PeriodSelect'; diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx new file mode 100644 index 0000000000..cc52012543 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography, Grid } from '@material-ui/core'; +import ProductInsightsCard from '../ProductInsightsCard'; +import { useConfig } from '../../hooks'; + +const ProductInsights = ({}) => { + const { products } = useConfig(); + + return ( + <> + + + Your team's product usage + + + + {products.map(product => ( + + + + ))} + + + ); +}; + +export default ProductInsights; diff --git a/plugins/cost-insights/src/components/ProductInsights/index.ts b/plugins/cost-insights/src/components/ProductInsights/index.ts new file mode 100644 index 0000000000..0a20d3c322 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsights/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 { default } from './ProductInsights'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx new file mode 100644 index 0000000000..6d40fc276a --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -0,0 +1,167 @@ +/* + * 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 ProductInsightsCard from './ProductInsightsCard'; +import { + MockComputeEngine, + createMockEntity, + mockDefaultState, + createMockProductCost, +} 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 { + MockConfigProvider, + MockFilterProvider, + MockCurrencyProvider, + MockScrollProvider, +} from '../../utils/tests'; + +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 getApis = (productCost: ProductCost) => { + return ApiRegistry.from([ + [identityApiRef, identityApi], + [costInsightsApiRef, costInsightsApi(productCost)], + ]); +}; + +const mockProductCost = createMockProductCost(() => ({ + entities: [], + aggregation: [3000, 4000], + change: { + ratio: 0.23, + amount: 1000, + }, +})); + +const renderProductInsightsCardInTestApp = async ( + productCost: ProductCost, + product: Product, +) => + await renderInTestApp( + + + + + + + + + + + + + + + , + ); + +describe('', () => { + it('Renders the scroll anchors', async () => { + const rendered = await renderProductInsightsCardInTestApp( + mockProductCost, + MockComputeEngine, + ); + expect( + rendered.queryByTestId(`scroll-test-compute-engine`), + ).toBeInTheDocument(); + }); + + it('Should render the right subheader for products with cost data', async () => { + const productCost = { + ...mockProductCost, + entities: [...Array(1000)].map(createMockEntity), + }; + const rendered = await renderProductInsightsCardInTestApp( + productCost, + MockComputeEngine, + ); + const subheader = 'entities, sorted by cost'; + const subheaderRgx = new RegExp( + `${productCost.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 subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`; + const rendered = await renderProductInsightsCardInTestApp( + productCost, + MockComputeEngine, + ); + const subheaderRgx = new RegExp(subheader); + expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); + expect( + rendered.queryByTestId('.resource-growth-chart-legend'), + ).not.toBeInTheDocument(); + expect( + rendered.queryByTestId('.insights-bar-chart'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx new file mode 100644 index 0000000000..1d24139c64 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -0,0 +1,147 @@ +/* + * 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, useEffect, useState } from 'react'; +import { InfoCard, useApi } from '@backstage/core'; +import { Box } from '@material-ui/core'; +import Alert from '@material-ui/lab/Alert'; +import { costInsightsApiRef } from '../../api'; +import PeriodSelect from '../PeriodSelect'; +import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; +import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; +import { useFilters, useLoading, useScroll } from '../../hooks'; +import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; +import { mapFiltersToProps, mapLoadingToProps } from './selector'; +import { Duration, Maybe, Product, ProductCost } from '../../types'; +import { pluralOf } from '../../utils/grammar'; + +type ProductInsightsCardProps = { + product: Product; +}; + +const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { + const client = useApi(costInsightsApiRef); + const classes = useStyles(); + const { ScrollAnchor } = useScroll(product.kind); + const [resource, setResource] = useState>(null); + const [error, setError] = useState>(null); + + const { group, product: productFilter, setProduct } = useFilters( + mapFiltersToProps(product.kind), + ); + const { loadingProduct, dispatchLoading } = useLoading( + mapLoadingToProps(product.kind), + ); + + // @see CostInsightsPage + // 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 subheader = amount + ? `${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; + + useEffect(() => { + async function load() { + if (loadingProduct) { + try { + const p: ProductCost = await client.getProductInsights( + product.kind, + group!, + productFilter!.duration, + ); + setResource(p); + } catch (e) { + setError(e); + } finally { + dispatchLoadingProduct(false); + } + } + } + load(); + }, [ + client, + product, + setResource, + loadingProduct, + dispatchLoadingProduct, + productFilter, + group, + product.kind, + ]); + + const onPeriodSelect = (duration: Duration) => { + dispatchLoadingProduct(true); + setProduct(duration); + }; + + const infoCardProps = { + headerProps: { + classes: classes, + action: ( + + ), + }, + }; + + if (error) { + return ( + + + {`Error: Could not fetch product insights for ${product.name}`} + + ); + } + + if (!resource) { + return null; + } + + return ( + + + {hasCostsWithinTimeframe && ( + <> + + + + + + + + )} + + ); +}; + +export default ProductInsightsCard; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/index.ts b/plugins/cost-insights/src/components/ProductInsightsCard/index.ts new file mode 100644 index 0000000000..ef378004f0 --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/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 { default } from './ProductInsightsCard'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts new file mode 100644 index 0000000000..001ff717bd --- /dev/null +++ b/plugins/cost-insights/src/components/ProductInsightsCard/selector.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MapFiltersToProps } from '../../hooks/useFilters'; +import { MapLoadingToProps } from '../../hooks/useLoading'; +import { Duration, PageFilters, ProductPeriod, findAlways } from '../../types'; + +type ProductInsightsCardFilterProps = PageFilters & { + product: ProductPeriod; + setProduct: (duration: Duration) => void; +}; + +type ProductInsightsCardLoadingProps = { + loadingProduct: boolean; + dispatchLoading: (isLoading: boolean) => void; +}; + +export const mapFiltersToProps = ( + product: string, +): MapFiltersToProps => ({ + pageFilters, + productFilters, + setProductFilters, +}) => ({ + ...pageFilters, + product: findAlways(productFilters, p => p.productType === product), + setProduct: (duration: Duration) => + setProductFilters( + productFilters.map(period => + period.productType === product ? { ...period, duration } : period, + ), + ), +}); + +export const mapLoadingToProps = ( + product: string, +): MapLoadingToProps => ({ + state, + dispatch, +}) => ({ + loadingProduct: state[product], + dispatchLoading: (isLoading: boolean) => dispatch({ [product]: isLoading }), +}); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx new file mode 100644 index 0000000000..5c3856cc9e --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -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 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'; + +const engineers = findAlways(defaultCurrencies, c => c.kind === null); + +const MockProject = 'test-project-1'; +const MockAlertCosts: AlertCost[] = [ + { id: 'test-id-1', aggregation: [150, 200] }, + { id: 'test-id-2', aggregation: [235, 400] }, +]; + +const MockProjectGrowthAlert = createMockProjectGrowthAlert(alert => ({ + ...alert, + project: MockProject, + products: MockAlertCosts, +})); + +describe('', () => { + it('renders the correct title and subheader for multiple services', async () => { + const subheader = new RegExp( + `${MockAlertCosts.length} products, sorted by cost`, + ); + const title = new RegExp(`Project growth for ${MockProject}`); + const rendered = await renderInTestApp( + + + , + + , + ); + expect(rendered.getByText(title)).toBeInTheDocument(); + expect(rendered.getByText(subheader)).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(); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx new file mode 100644 index 0000000000..c27b201202 --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -0,0 +1,60 @@ +/* + * 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 } from '@material-ui/core'; +import { InfoCard } from '@backstage/core'; +import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; +import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; +import { Duration, ProjectGrowthAlert } from '../../types'; +import { pluralOf } from '../../utils/grammar'; + +type ProjectGrowthAlertProps = { + alert: ProjectGrowthAlert; +}; + +const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { + const [costStart, costEnd] = alert.aggregation; + + const subheader = ` + ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ + alert.products.length > 1 ? ', sorted by cost' : '' + }`; + + return ( + + + + + + + + + ); +}; + +export default ProjectGrowthAlertCard; diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/index.ts new file mode 100644 index 0000000000..ab608f5f4c --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/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 { default } from './ProjectGrowthAlertCard'; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx new file mode 100644 index 0000000000..f8f4a90a9e --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -0,0 +1,212 @@ +/* + * 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 { InfoCard } from '@backstage/core'; +import AlertInstructionsLayout from '../AlertInstructionsLayout'; +import ProjectGrowthAlertCard from '../ProjectGrowthAlertCard'; +import { + AlertType, + Duration, + Entity, + Product, + ProjectGrowthAlert, +} from '../../types'; +import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; +import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; + +const ProjectGrowthInstructionsPage = () => { + const projectGrowthAlert: ProjectGrowthAlert = { + id: AlertType.ProjectGrowth, + project: 'example-project', + periodStart: 'Q1 2020', + periodEnd: 'Q2 2020', + aggregation: [60000, 120000], + change: { + ratio: 1, + amount: 60000, + }, + products: [ + { + id: 'Compute Engine', + aggregation: [58000, 118000], + }, + { + id: 'Cloud Dataflow', + aggregation: [1200, 1500], + }, + { + id: 'Cloud Storage', + aggregation: [800, 500], + }, + ], + }; + + const product: Product = { + kind: 'ComputeEngine', + name: 'Compute Engine', + }; + + const entities: Entity[] = [ + { + id: 'service-one', + aggregation: [18200, 58500], + }, + { + id: 'service-two', + aggregation: [1200, 1300], + }, + { + id: 'service-three', + aggregation: [600, 200], + }, + ]; + + return ( + + Investigating cloud cost growth + + Cost Insights shows an alert when costs for a particular billing entity, + such as a GCP project, have grown at a rate faster than our alerting + threshold. The responsible team should follow this guide to decide + whether this warrants further investigation. + + + + Is the growth expected? + + The first question to ask is whether growth is expected. Perhaps a new + product has been deployed, or additional regions added for + reliability. + + + Many services increase cost linearly with load. Has the demand + increased? This may happen as you open new markets, or run marketing + offers. Costs should be compared against a business metric, such as + daily users, to normalize natural increases from business growth. + + + Seasonal variance may also cause cost growth; yearly campaigns, an + increase in demand during certain times of year. + + + Cloud costs will often go up before they go down, in the case of + migrations. Teams moving to new infrastructure may run in both the old + and new environment during the migration. + + + + + Is the growth significant? + + Next, evaluate whether the growth is significant. This helps avoid + premature optimization, where cost in engineering time is more than + would be saved from the optimization over a reasonable timeframe. + + + We recommend reframing the cost growth itself in terms of engineering + time. How much engineering time, for an average fully-loaded + engineer cost at the company, is being overspent each month? Compare + this to expected engineering time for optimization to decide whether + the optimization is worthwhile. + + + + + + Identifying which cloud product contributed most + + + For projects meeting the alert threshold, Cost Insights shows a cost + comparison of cloud products over the examined time period: + + + + + + This allows you to quickly see which cloud products contributed to the + growth in cloud costs. + + + + + + Identifying the responsible workload + + + After identifying the cloud product, use the corresponding product + panel in Cost Insights to find a particular workload (or entity + ) that has grown in cost: + + + {/* ProductInsightsCard without API query / PeriodSelect */} + + + + + + + + + + + + + From here, you can dig into commit history or deployment logs to find + probable causes of an unexpected spike in cost. + + + + + Optimizing the workload + + Workload optimization varies between cloud products, but there are a + few general optimization areas to consider: + + Retention + + Is the workload or storage necessary? Truly idle or unused resources + can be cleaned up for immediate cost savings. For storage, how long do + we need the data? Many cloud products support retention policies to + automatically delete data after a certain time period. + + Efficiency + + Is the workload using cloud resources efficiently? For compute + resources, do the utilization metrics look reasonable? Autoscaling + infrastructure, such as Kubernetes, can run workloads more efficiently + without comprimising reliability. + + Lifecycle + + Is the workload using an optimal pricing model? Some cloud products + offer better pricing for data that is accessed less frequently. + + + + ); +}; + +export default ProjectGrowthInstructionsPage; diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/index.ts new file mode 100644 index 0000000000..c75240405c --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/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 { default } from './ProjectGrowthInstructionsPage'; diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx new file mode 100644 index 0000000000..92de819fe0 --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx @@ -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 React from 'react'; +import { getByRole, waitFor } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; +import ProjectSelect from './ProjectSelect'; +import { MockFilterProvider } from '../../utils/tests'; +import { renderInTestApp } from '@backstage/test-utils'; + +const mockProjects = [ + { id: 'project1' }, + { id: 'project2' }, + { id: 'project3' }, +]; + +const mockSetPageFilters = jest.fn(); + +describe('', () => { + let Component: React.ReactNode; + beforeEach(() => { + Component = () => ( + + + + ); + }); + + it('Renders without exploding', async () => { + const rendered = await renderInTestApp(Component); + expect(rendered.getByText('All Projects')).toBeInTheDocument(); + }); + + it('shows all projects in the filter select', async () => { + const rendered = await renderInTestApp(Component); + const projectSelectContainer = rendered.getByTestId( + 'project-filter-select', + ); + 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(), + ); + }); +}); diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx new file mode 100644 index 0000000000..cbcd8ede4e --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.tsx @@ -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 React from 'react'; +import { MenuItem, Select } from '@material-ui/core'; +import { Maybe, Project } from '../../types'; +import { useSelectStyles as useStyles } from '../../utils/styles'; + +type ProjectSelectProps = { + project: Maybe; + projects: Array; + onSelect: (project: Maybe) => void; +}; + +const ProjectSelect = ({ project, projects, onSelect }: ProjectSelectProps) => { + const classes = useStyles(); + + const projectOptions = [{ id: 'all' } as Project, ...projects] + .filter(p => p.id) + .sort((a, b) => (a.id as string).localeCompare(b.id as string)); + + const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => { + onSelect(e.target.value as string); + }; + + const renderValue = (value: unknown) => { + const proj = value as string; + return ( + + {proj === 'all' ? 'All Projects' : proj} + + ); + }; + + return ( + + ); +}; + +export default ProjectSelect; diff --git a/plugins/cost-insights/src/components/ProjectSelect/index.ts b/plugins/cost-insights/src/components/ProjectSelect/index.ts new file mode 100644 index 0000000000..47a2b9e22c --- /dev/null +++ b/plugins/cost-insights/src/components/ProjectSelect/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 { default } from './ProjectSelect'; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx new file mode 100644 index 0000000000..8355deab7d --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.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 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 new file mode 100644 index 0000000000..3ae35ba871 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx @@ -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 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/ResourceGrowthBarChart/index.ts b/plugins/cost-insights/src/components/ResourceGrowthBarChart/index.ts new file mode 100644 index 0000000000..b1388143e4 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/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 { default } from './ResourceGrowthBarChart'; diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx new file mode 100644 index 0000000000..1e432ff3bc --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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 new file mode 100644 index 0000000000..1466e18ee1 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx @@ -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 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/ResourceGrowthBarChartLegend/index.ts b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/index.ts new file mode 100644 index 0000000000..7071799805 --- /dev/null +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/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 { default } from './ResourceGrowthBarChartLegend'; diff --git a/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx b/plugins/cost-insights/src/components/Tooltip/Tooltip.test.tsx new file mode 100644 index 0000000000..8c12b34ffb --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/Tooltip.test.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 { renderInTestApp } from '@backstage/test-utils'; +import Tooltip from './Tooltip'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const mockTooltipItems = [ + { + label: 'Cost', + value: '$1,000,000', + fill: '#FFF', + }, + { + label: 'Test Metric', + value: '100,000,000', + fill: '#FFF', + }, +]; + +describe('', () => { + it('renders without exploding', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect( + rendered.container.querySelector('.tooltip-content'), + ).toBeInTheDocument(); + }); + + it('formats label and tooltip item text correctly', async () => { + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByText('05/16/2020')).toBeInTheDocument(); + expect(rendered.getByText('Cost')).toBeInTheDocument(); + expect(rendered.getByText('Test Metric')).toBeInTheDocument(); + expect(rendered.getByText('$1,000,000')).toBeInTheDocument(); + expect(rendered.getByText('100,000,000')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx b/plugins/cost-insights/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000000..727b2476c8 --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/Tooltip.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 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/TooltipItem.tsx b/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx new file mode 100644 index 0000000000..67d4eac15f --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/TooltipItem.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography } from '@material-ui/core'; +import LensIcon from '@material-ui/icons/Lens'; +import { useTooltipStyles as useStyles } from '../../utils/styles'; + +export type TooltipItemProps = { + value: string; + label: string; + fill: string; +}; + +const TooltipItem = ({ fill, label, value }: TooltipItemProps) => { + const classes = useStyles(); + const style = { fill: fill }; + return ( + + + + + + {label} + + {value} + + ); +}; + +export default TooltipItem; diff --git a/plugins/cost-insights/src/components/Tooltip/index.ts b/plugins/cost-insights/src/components/Tooltip/index.ts new file mode 100644 index 0000000000..ca20ab1242 --- /dev/null +++ b/plugins/cost-insights/src/components/Tooltip/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 { 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 new file mode 100644 index 0000000000..6beb204b0d --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 UnlabeledDataflowAlertCard from './UnlabeledDataflowAlertCard'; +import { + createMockUnlabeledDataflowAlert, + createMockUnlabeledDataflowAlertProject, +} from '../../utils/mockData'; +import { renderInTestApp } from '@backstage/test-utils'; + +const MockUnlabeledDataflowAlertMultipleProjects = createMockUnlabeledDataflowAlert( + alert => ({ + ...alert, + projects: [...Array(10)].map(() => + createMockUnlabeledDataflowAlertProject(), + ), + }), +); + +const MockUnlabeledDataflowAlertSingleProject = createMockUnlabeledDataflowAlert( + alert => ({ + ...alert, + projects: [...Array(1)].map(() => + createMockUnlabeledDataflowAlertProject(), + ), + }), +); + +describe('', () => { + it('renders the correct subheader for multiple projects', async () => { + const subheader = new RegExp( + `Showing costs from ${MockUnlabeledDataflowAlertMultipleProjects.projects.length} ` + + 'projects with unlabeled Dataflow jobs in the last 30 days.', + ); + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); + + it('renders the correct subheader for a single project', async () => { + const subheader = new RegExp('1 project'); + const rendered = await renderInTestApp( + , + ); + expect(rendered.getByText(subheader)).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx new file mode 100644 index 0000000000..8dff85e867 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/UnlabeledDataflowAlertCard.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 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 { pluralOf } from '../../utils/grammar'; + +type UnlabeledDataflowAlertProps = { + alert: UnlabeledDataflowAlert; +}; + +const UnlabeledDataflowAlertCard = ({ alert }: UnlabeledDataflowAlertProps) => { + 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. + `; + return ( + + + + + + + + + + + ); +}; + +export default UnlabeledDataflowAlertCard; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/index.ts new file mode 100644 index 0000000000..6f22abc062 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowAlertCard/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 { default } from './UnlabeledDataflowAlertCard'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx new file mode 100644 index 0000000000..7b1f4e31ec --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/UnlabeledDataflowBarChart.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 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/UnlabeledDataflowBarChart/index.ts b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/index.ts new file mode 100644 index 0000000000..6f475d5a80 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChart/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 { default } from './UnlabeledDataflowBarChart'; diff --git a/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx new file mode 100644 index 0000000000..9ab4e90001 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.test.tsx @@ -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 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 new file mode 100644 index 0000000000..68caaed6d0 --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/UnlabeledDataflowBarChartLegend.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 { 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 new file mode 100644 index 0000000000..470722e60a --- /dev/null +++ b/plugins/cost-insights/src/components/UnlabeledDataflowBarChartLegend/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 { default } from './UnlabeledDataflowBarChartLegend'; diff --git a/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx b/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx new file mode 100644 index 0000000000..69de854b00 --- /dev/null +++ b/plugins/cost-insights/src/components/WhyCostsMatter/WhyCostsMatter.tsx @@ -0,0 +1,77 @@ +/* + * 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 { Typography, Box, Grid, Container, Divider } from '@material-ui/core'; + +const WhyCostsMatter = () => { + return ( + + + + + Why cloud costs matter + + + + + + Sustainability{' '} + + 🌎 + + + + Reducing cloud usage improves our carbon footprint. + + + + + + + + Revenue{' '} + + 💸 + + + + Keeping cloud costs well-tuned prevents infrastructure from eating + into revenue. + + + + + + + + Innovation{' '} + + đŸĨ‡ + + + + The more we save, the more we can reinvest in speed and + innovation. + + + + + + ); +}; + +export default WhyCostsMatter; diff --git a/plugins/cost-insights/src/components/WhyCostsMatter/index.ts b/plugins/cost-insights/src/components/WhyCostsMatter/index.ts new file mode 100644 index 0000000000..ed60207fde --- /dev/null +++ b/plugins/cost-insights/src/components/WhyCostsMatter/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 { default } from './WhyCostsMatter'; diff --git a/plugins/cost-insights/src/hooks/index.ts b/plugins/cost-insights/src/hooks/index.ts new file mode 100644 index 0000000000..7065bcf91b --- /dev/null +++ b/plugins/cost-insights/src/hooks/index.ts @@ -0,0 +1,24 @@ +/* + * 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 './useConfig'; +export * from './useCurrency'; +export * from './useFilters'; +export * from './useCurrency'; +export * from './useGroups'; +export * from './useLoading'; +export * from './useQueryParams'; +export * from './useScroll'; diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx new file mode 100644 index 0000000000..aee6567056 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -0,0 +1,162 @@ +/* + * 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, + createContext, + useContext, + useEffect, + useState, +} from 'react'; +import { useApi, configApiRef } from '@backstage/core'; +import { Config as BackstageConfig } from '@backstage/config'; +import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types'; +import { getIcon } from '../utils/navigation'; + +export const NULL_METRIC = 'dailyCost'; +export const NULL_METRIC_NAME = 'Daily Cost'; + +/* + * Config schema 2020-09-28 + * + * costInsights: + * engineerCost: 200000 + * products: + * productA: + * name: Product A + * icon: storage + * productB: + * name: Product B + * icon: data + * metrics: + * metricA: + * name: Metric A + * metricB: + * name: Metric B + */ + +export type ConfigContextProps = { + metrics: Metric[]; + products: Product[]; + icons: Icon[]; + engineerCost: number; + currencies: Currency[]; +}; + +export const ConfigContext = createContext( + undefined, +); + +const defaultState: ConfigContextProps = { + metrics: [{ kind: null, name: NULL_METRIC_NAME }], + products: [], + icons: [], + engineerCost: 0, + currencies: defaultCurrencies, +}; + +export const ConfigProvider = ({ children }: { children: ReactNode }) => { + const c: BackstageConfig = useApi(configApiRef); + const [config, setConfig] = useState(defaultState); + const [loading, setLoading] = useState(true); + + useEffect(() => { + function getProducts(): Product[] { + const products = c.getConfig('costInsights.products'); + return products.keys().map(key => ({ + kind: key, + name: products.getString(`${key}.name`), + aggregation: [0, 0], + })); + } + + function getMetrics(): Metric[] { + const metrics = c.getOptionalConfig('costInsights.metrics'); + if (metrics) { + return metrics.keys().map(key => ({ + kind: key === NULL_METRIC ? null : key, + name: metrics.getString(`${key}.name`), + })); + } + + return []; + } + + function getIcons(): Icon[] { + const products = c.getConfig('costInsights.products'); + const keys = products.keys(); + + return keys.map(k => ({ + kind: k, + component: getIcon(products.getOptionalString(`${k}.icon`)), + })); + } + + function getEngineerCost(): number { + return c.getNumber('costInsights.engineerCost'); + } + + function getConfig() { + const products = getProducts(); + const metrics = getMetrics(); + 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, + })); + } + setLoading(false); + } + + getConfig(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (loading) { + return null; + } + + return ( + {children} + ); +}; + +export function useConfig(): ConfigContextProps { + const config = useContext(ConfigContext); + + if (!config) { + assertNever(); + } + + return config; +} + +function assertNever(): never { + throw new Error('Cannot use useConfig outside of ConfigProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useCurrency.tsx b/plugins/cost-insights/src/hooks/useCurrency.tsx new file mode 100644 index 0000000000..f5d0df929a --- /dev/null +++ b/plugins/cost-insights/src/hooks/useCurrency.tsx @@ -0,0 +1,60 @@ +/* + * 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, { + Dispatch, + SetStateAction, + ReactNode, + useState, + useContext, +} from 'react'; +import { Currency, defaultCurrencies, findAlways } from '../types'; + +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) => { + const engineers = findAlways(defaultCurrencies, c => c.kind === null); + const [currency, setCurrency] = useState(engineers); + return ( + + {children} + + ); +}; + +export function useCurrency(): [Currency, Dispatch>] { + const context = useContext(CurrencyContext); + + if (!context) { + assertNever(); + } + + return [context.currency, context.setCurrency]; +} + +function assertNever(): never { + throw Error('Cannot use useCurrency outside of CurrencyProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useFilters.tsx b/plugins/cost-insights/src/hooks/useFilters.tsx new file mode 100644 index 0000000000..c2053d6b0f --- /dev/null +++ b/plugins/cost-insights/src/hooks/useFilters.tsx @@ -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. + */ + +import React, { + Dispatch, + ReactNode, + SetStateAction, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { + getDefaultPageFilters, + PageFilters, + ProductFilters, + Duration, + Group, +} from '../types'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useQueryParams } from './useQueryParams'; +import { stringify } 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>; +}; + +export type MapFiltersToProps = (props: FilterContextProps) => T; + +export type FilterProviderProps = { + children: ReactNode; +}; + +export const FilterContext = React.createContext< + FilterContextProps | undefined +>(undefined); + +export const FilterProvider = ({ children }: FilterProviderProps) => { + 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), + ); + + // TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded. + useEffect(() => { + setPageFilters(getInitialPageState(groups, queryParams.pageFilters)); + }, [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 + + return ( + + {children} + + ); +}; + +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, + }); +} + +function assertNever(): never { + throw Error('Cannot use useFilters outside of FilterProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useGroups.tsx b/plugins/cost-insights/src/hooks/useGroups.tsx new file mode 100644 index 0000000000..02c32e7920 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useGroups.tsx @@ -0,0 +1,84 @@ +/* + * 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, useContext, useEffect, useState } from 'react'; +import { useApi, identityApiRef } from '@backstage/core'; +import { costInsightsApiRef } from '../api'; +import { MapLoadingToProps, useLoading } from './useLoading'; +import { DefaultLoadingAction, Group } from '../types'; + +type GroupsProviderLoadingProps = { + dispatchLoadingGroups: (isLoading: boolean) => void; +}; + +export const mapLoadingToProps: MapLoadingToProps = ({ + dispatch, +}) => ({ + dispatchLoadingGroups: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.UserGroups]: isLoading }), +}); + +type GroupsContextProps = { + groups: Group[]; +}; + +export type GroupsProviderProps = { + children: ReactNode; +}; + +export const GroupsContext = React.createContext({ + groups: [], +}); + +export const GroupsProvider = ({ children }: GroupsProviderProps) => { + const userId = useApi(identityApiRef).getUserId(); + const client = useApi(costInsightsApiRef); + const { dispatchLoadingGroups } = useLoading(mapLoadingToProps); + + const [groups, setGroups] = useState([]); + + useEffect(() => { + dispatchLoadingGroups(true); + + async function getUserGroups() { + const g = await client.getUserGroups(userId); + setGroups(g); + dispatchLoadingGroups(false); + } + + getUserGroups(); + }, [userId, client]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {children} + + ); +}; + +export function useGroups(): Group[] { + const { groups } = useContext(GroupsContext); + + if (!groups) { + assertNever(); + } + + return groups; +} + +function assertNever(): never { + throw Error('Cannot use useGroups outside of GroupsProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useLoading.tsx b/plugins/cost-insights/src/hooks/useLoading.tsx new file mode 100644 index 0000000000..bc0df4bbc7 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useLoading.tsx @@ -0,0 +1,107 @@ +/* + * 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, { + Dispatch, + ReactNode, + SetStateAction, + createContext, + useContext, + useEffect, + useMemo, + useReducer, + useState, +} from 'react'; +import { Backdrop, CircularProgress } from '@material-ui/core'; +import { + Loading, + getDefaultState, + DefaultLoadingAction, + getLoadingActions, +} from '../types'; +import { useBackdropStyles as useStyles } from '../utils/styles'; +import { useConfig } from './useConfig'; + +export type LoadingContextProps = { + state: Loading; + dispatch: Dispatch>>; + actions: Array; +}; + +export type LoadingProviderProps = { + children: ReactNode; +}; + +export type MapLoadingToProps = (props: LoadingContextProps) => T; + +export const LoadingContext = createContext( + undefined, +); + +function reducer(prevState: Loading, action: Partial): Loading { + return { + ...prevState, + ...action, + } as Record; +} + +export const LoadingProvider = ({ children }: LoadingProviderProps) => { + const classes = useStyles(); + const { products } = useConfig(); + const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [ + products, + ]); + const [state, dispatch] = useReducer(reducer, getDefaultState(actions)); + const [isBackdropVisible, setBackdropVisible] = useState(false); + + useEffect(() => { + function displayLoadingBackdrop() { + // Initial page loading is handled by progress bar + setBackdropVisible( + !state[DefaultLoadingAction.CostInsightsInitial] && + Object.values(state).some(l => l), + ); + } + displayLoadingBackdrop(); + }, [state, setBackdropVisible]); + + return ( + + {children} + + + + + ); +}; + +export function useLoading(mapLoadingToProps: MapLoadingToProps): T { + const context = useContext(LoadingContext); + + if (!context) { + assertNever(); + } + + return mapLoadingToProps({ + state: context.state, + actions: context.actions, + dispatch: context.dispatch, + }); +} + +function assertNever(): never { + throw Error('useLoading cannot be used outside of LoadingProvider'); +} diff --git a/plugins/cost-insights/src/hooks/useQueryParams.tsx b/plugins/cost-insights/src/hooks/useQueryParams.tsx new file mode 100644 index 0000000000..1e6ce8f0fd --- /dev/null +++ b/plugins/cost-insights/src/hooks/useQueryParams.tsx @@ -0,0 +1,35 @@ +/* + * 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 { useLocation } from 'react-router-dom'; +import { Location } from 'history'; +import { PageFilters, ProductFilters } from '../types'; +import { parse } from '../utils/history'; + +export type FilterParams = { + pageFilters?: Partial; + productFilters?: ProductFilters; +}; + +export function useQueryParams(): FilterParams { + const location: Location = useLocation(); + const { products: productFilters, ...pageFilters } = parse(location.search); + + return { + productFilters: productFilters, + pageFilters: pageFilters, + }; +} diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx new file mode 100644 index 0000000000..480495a257 --- /dev/null +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -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 React, { + Dispatch, + ReactNode, + SetStateAction, + useState, + useContext, + useEffect, + useRef, +} from 'react'; +import { CSSProperties } from '@material-ui/styles'; +import { Maybe } from '../types'; + +export type ScrollTo = Maybe; + +export type ScrollContextProps = { + scrollTo: ScrollTo; + setScrollTo: Dispatch>; +}; + +export type ScrollProviderProps = { + children: ReactNode; +}; + +export type ScrollUtils = { + ScrollAnchor: (props: Omit) => JSX.Element; + scrollIntoView: () => void; +}; + +export interface ScrollAnchorProps extends ScrollIntoViewOptions { + id: ScrollTo; + top?: number; + left?: number; +} + +export const ScrollContext = React.createContext< + ScrollContextProps | undefined +>(undefined); + +export const ScrollAnchor = ({ + id, + top, + left, + behavior, + block, + inline, +}: ScrollAnchorProps) => { + const divRef = useRef(null); + const context = useContext(ScrollContext); + + if (!context) { + assertNever(); + } + + const { scrollTo, setScrollTo } = context; + + const styles: CSSProperties = { + position: 'absolute', + height: 0, + width: 0, + top: top || 0, + left: left || 0, + }; + + useEffect(() => { + function scrollIntoView() { + const options = { + behavior: behavior || 'auto', + block: block || 'start', + inline: inline || 'nearest', + }; + + if (divRef.current && scrollTo === id) { + divRef.current.scrollIntoView(options); + setScrollTo(null); + } + } + + scrollIntoView(); + }, [scrollTo, setScrollTo, id, behavior, block, inline]); + + return
; +}; + +export const ScrollProvider = ({ children }: ScrollProviderProps) => { + const [scrollTo, setScrollTo] = useState(null); + + return ( + + {children} + + ); +}; + +export function useScroll(id: ScrollTo): ScrollUtils { + const context = useContext(ScrollContext); + + if (!context) { + assertNever(); + } + + return { + ScrollAnchor: props => , + scrollIntoView: () => context.setScrollTo(id), + }; +} + +function assertNever(): never { + throw new Error( + `Cannot use useScroll or ScrollAnchor outside ScrollProvider`, + ); +} diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts new file mode 100644 index 0000000000..cf7aa3ea8c --- /dev/null +++ b/plugins/cost-insights/src/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 { plugin } from './plugin'; +export * from './api'; +export * from './types'; diff --git a/plugins/cost-insights/src/plugin.test.ts b/plugins/cost-insights/src/plugin.test.ts new file mode 100644 index 0000000000..6210e11d4c --- /dev/null +++ b/plugins/cost-insights/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('cost-insights', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/cost-insights/src/plugin.ts b/plugins/cost-insights/src/plugin.ts new file mode 100644 index 0000000000..d8d54ae71b --- /dev/null +++ b/plugins/cost-insights/src/plugin.ts @@ -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 { createPlugin, createRouteRef } 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', + title: 'Cost Insights', +}); + +export const projectGrowthAlertRef = createRouteRef({ + path: '/cost-insights/investigating-growth', + title: 'Investigating Growth', +}); + +export const unlabeledDataflowAlertRef = createRouteRef({ + path: '/cost-insights/labeling-jobs', + title: 'Labeling Dataflow Jobs', +}); + +export const plugin = createPlugin({ + id: 'cost-insights', + register({ router, featureFlags }) { + router.addRoute(rootRouteRef, CostInsightsPage); + router.addRoute(projectGrowthAlertRef, ProjectGrowthInstructionsPage); + router.addRoute(unlabeledDataflowAlertRef, LabelDataflowInstructionsPage); + featureFlags.register('cost-insights-currencies'); + }, +}); diff --git a/plugins/cost-insights/src/setupTests.ts b/plugins/cost-insights/src/setupTests.ts new file mode 100644 index 0000000000..8553642152 --- /dev/null +++ b/plugins/cost-insights/src/setupTests.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. + */ + +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 new file mode 100644 index 0000000000..8d560096e3 --- /dev/null +++ b/plugins/cost-insights/src/types/Alert.ts @@ -0,0 +1,77 @@ +/* + * 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 { ChangeStatistic } from './ChangeStatistic'; +import { Maybe } from './Maybe'; + +export type Alert = ProjectGrowthAlert | UnlabeledDataflowAlert; + +export interface AlertProps { + alert: Alert; +} + +export enum AlertType { + ProjectGrowth = 'projectGrowth', + UnlabeledDataflow = 'unlabeledDataflow', +} + +export interface AlertCost { + id: string; + aggregation: [number, number]; +} + +export interface ResourceData { + previous: number; + current: number; + name: Maybe; +} + +export interface BarChartData { + previousFill: string; + currentFill: string; + previousName: string; + currentName: string; +} + +export enum DataKey { + Previous = 'previous', + Current = 'current', + Name = 'name', +} + +export interface ProjectGrowthAlert { + id: AlertType.ProjectGrowth; + project: string; + periodStart: string; + periodEnd: string; + aggregation: [number, number]; + change: ChangeStatistic; + products: Array; +} + +export interface UnlabeledDataflowAlert { + id: AlertType.UnlabeledDataflow; + periodStart: string; + periodEnd: string; + projects: Array; + unlabeledCost: number; + labeledCost: number; +} + +export interface UnlabeledDataflowAlertProject { + id: string; + unlabeledCost: number; + labeledCost: number; +} diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts new file mode 100644 index 0000000000..538692bb1d --- /dev/null +++ b/plugins/cost-insights/src/types/ChangeStatistic.ts @@ -0,0 +1,48 @@ +/* + * 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 ChangeStatistic { + // The ratio of change from one duration to another, expressed as: (newSum - oldSum) / oldSum + ratio: number; + // The actual USD change between time periods (can be negative if costs decreased) + amount: number; +} + +export const EngineerThreshold = 0.5; + +export enum ChangeThreshold { + upper = 0.05, + lower = -0.05, +} + +export enum Growth { + 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/Cost.ts b/plugins/cost-insights/src/types/Cost.ts new file mode 100644 index 0000000000..84a8bfae51 --- /dev/null +++ b/plugins/cost-insights/src/types/Cost.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 { DateAggregation } from './DateAggregation'; +import { ChangeStatistic } from './ChangeStatistic'; +import { Trendline } from './Trendline'; + +export interface Cost { + id: string; + aggregation: DateAggregation[]; + change: ChangeStatistic; + trendline: Trendline; +} diff --git a/plugins/cost-insights/src/types/Currency.ts b/plugins/cost-insights/src/types/Currency.ts new file mode 100644 index 0000000000..633e405893 --- /dev/null +++ b/plugins/cost-insights/src/types/Currency.ts @@ -0,0 +1,78 @@ +/* + * 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 } 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; + label: string; + unit: string; + prefix?: string; + 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, + }, +]; diff --git a/plugins/cost-insights/src/types/DateAggregation.ts b/plugins/cost-insights/src/types/DateAggregation.ts new file mode 100644 index 0000000000..807387a265 --- /dev/null +++ b/plugins/cost-insights/src/types/DateAggregation.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type DateAggregation = { + date: string; // YYYY-MM-DD + amount: number; +}; diff --git a/plugins/cost-insights/src/types/Duration.test.ts b/plugins/cost-insights/src/types/Duration.test.ts new file mode 100644 index 0000000000..5b9f7b4a9b --- /dev/null +++ b/plugins/cost-insights/src/types/Duration.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Duration, inclusiveEndDateOf, inclusiveStartDateOf } from './Duration'; + +Date.now = jest.fn(() => new Date(Date.parse('2020-06-05')).valueOf()); + +describe.each` + duration | startDate | endDate + ${Duration.P30D} | ${'2020-04-06'} | ${'2020-06-05'} + ${Duration.P90D} | ${'2019-12-08'} | ${'2020-06-05'} + ${Duration.P1M} | ${'2020-04-01'} | ${'2020-05-31'} + ${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); + }); +}); diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts new file mode 100644 index 0000000000..6e6c5b1100 --- /dev/null +++ b/plugins/cost-insights/src/types/Duration.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 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 + * it's September 15, P1M represents costs for the month of August and P30D represents August 16 - + * September 15. + */ +export enum Duration { + P30D = 'P30D', + P90D = 'P90D', + P1M = 'P1M', + P3M = 'P3M', +} + +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 new file mode 100644 index 0000000000..f5ac620289 --- /dev/null +++ b/plugins/cost-insights/src/types/Entity.ts @@ -0,0 +1,24 @@ +/* + * 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 { ChangeStatistic } from './ChangeStatistic'; +import { Maybe } from './Maybe'; + +export interface Entity { + id: Maybe; + aggregation: [number, number]; + change?: Maybe; +} diff --git a/plugins/cost-insights/src/types/Filters.ts b/plugins/cost-insights/src/types/Filters.ts new file mode 100644 index 0000000000..def1d74d1a --- /dev/null +++ b/plugins/cost-insights/src/types/Filters.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. + */ + +import { Maybe } from './Maybe'; +import { Duration } from './Duration'; +import { Group } from './Group'; + +export interface PageFilters { + group: Maybe; + project: Maybe; + duration: Duration; + metric: Maybe; +} + +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/identity-backend/src/adapters/types.ts b/plugins/cost-insights/src/types/Group.ts similarity index 62% rename from plugins/identity-backend/src/adapters/types.ts rename to plugins/cost-insights/src/types/Group.ts index 59fd1324d1..3aef92e5b9 100644 --- a/plugins/identity-backend/src/adapters/types.ts +++ b/plugins/cost-insights/src/types/Group.ts @@ -14,29 +14,6 @@ * limitations under the License. */ -export type User = { - name: string; -}; - export type Group = { - name: string; - type: string; - members?: User[]; - children?: Group[]; + id: string; }; - -export type GroupsJson = { - groups: Group[]; -}; - -export type GroupsResponse = { - groups: Group[]; -}; - -export type GroupsRequest = { - user: string; - type: string; -}; -export interface IdentityApi { - getUserGroups(req: GroupsRequest): Promise; -} diff --git a/plugins/cost-insights/src/types/Icon.ts b/plugins/cost-insights/src/types/Icon.ts new file mode 100644 index 0000000000..70f43a6813 --- /dev/null +++ b/plugins/cost-insights/src/types/Icon.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. + */ + +export type Icon = { + kind: string; + component: JSX.Element; +}; + +export enum IconType { + Compute = 'compute', + Data = 'data', + Database = 'database', + Storage = 'storage', + Search = 'search', + ML = 'ml', +} diff --git a/plugins/cost-insights/src/types/Loading.ts b/plugins/cost-insights/src/types/Loading.ts new file mode 100644 index 0000000000..f5da1d2b07 --- /dev/null +++ b/plugins/cost-insights/src/types/Loading.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. + */ + +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 new file mode 100644 index 0000000000..d721f28d75 --- /dev/null +++ b/plugins/cost-insights/src/types/Maybe.ts @@ -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. + */ +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 new file mode 100644 index 0000000000..5a440a8d1f --- /dev/null +++ b/plugins/cost-insights/src/types/Metric.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 { Maybe } from './Maybe'; + +export type Metric = { + kind: Maybe; + name: string; +}; diff --git a/plugins/cost-insights/src/types/Product.ts b/plugins/cost-insights/src/types/Product.ts new file mode 100644 index 0000000000..d2087644cb --- /dev/null +++ b/plugins/cost-insights/src/types/Product.ts @@ -0,0 +1,30 @@ +/* + * 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'; +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/Project.ts b/plugins/cost-insights/src/types/Project.ts new file mode 100644 index 0000000000..24259f6910 --- /dev/null +++ b/plugins/cost-insights/src/types/Project.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 interface Project { + id: string; +} diff --git a/plugins/cost-insights/src/types/Theme.ts b/plugins/cost-insights/src/types/Theme.ts new file mode 100644 index 0000000000..9053283351 --- /dev/null +++ b/plugins/cost-insights/src/types/Theme.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 { BackstagePalette, BackstageTheme } from '@backstage/theme'; +import { PaletteOptions } from '@material-ui/core/styles/createPalette'; + +type CostInsightsTooltipOptions = { + background: string; + color: string; +}; + +type CostInsightsPaletteAdditions = { + blue: string; + lightBlue: string; + darkBlue: string; + magenta: string; + yellow: string; + tooltip: CostInsightsTooltipOptions; + navigationText: string; + alertBackground: string; +}; + +export type CostInsightsPalette = BackstagePalette & + CostInsightsPaletteAdditions; + +export type CostInsightsPaletteOptions = PaletteOptions & + CostInsightsPaletteAdditions; + +export interface CostInsightsThemeOptions extends PaletteOptions { + palette: CostInsightsPaletteOptions; +} + +export interface CostInsightsTheme extends BackstageTheme { + palette: CostInsightsPalette; +} diff --git a/plugins/cost-insights/src/types/Trendline.ts b/plugins/cost-insights/src/types/Trendline.ts new file mode 100644 index 0000000000..aad7ba0bac --- /dev/null +++ b/plugins/cost-insights/src/types/Trendline.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 type Trendline = { + slope: number; + intercept: number; +}; diff --git a/plugins/cost-insights/src/types/index.ts b/plugins/cost-insights/src/types/index.ts new file mode 100644 index 0000000000..c3d9a87886 --- /dev/null +++ b/plugins/cost-insights/src/types/index.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. + */ + +export * from './Alert'; +export * from './ChangeStatistic'; +export * from './Cost'; +export * from './DateAggregation'; +export * from './Duration'; +export * from './Currency'; +export * from './Entity'; +export * from './Icon'; +export * from './Filters'; +export * from './Group'; +export * from './Loading'; +export * from './Maybe'; +export * from './Metric'; +export * from './Product'; +export * from './Project'; +export * from './Theme'; +export * from './Trendline'; diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx new file mode 100644 index 0000000000..07602be191 --- /dev/null +++ b/plugins/cost-insights/src/utils/alerts.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 React from 'react'; +import { Alert, AlertType, assertNever } from '../types'; +import ProjectGrowthAlertCard from '../components/ProjectGrowthAlertCard'; +import UnlabeledDataflowAlertCard from '../components/UnlabeledDataflowAlertCard'; + +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); + } +} + +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 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/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts new file mode 100644 index 0000000000..a76e838b53 --- /dev/null +++ b/plugins/cost-insights/src/utils/formatters.test.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 { lengthyCurrencyFormatter, quarterOf } from './formatters'; + +Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf()); + +describe('date formatters', () => { + it('Formats quarters', () => { + expect(quarterOf('2020-01-01')).toBe('Q1 2020'); + expect(quarterOf('2020-02-29')).toBe('Q1 2020'); + expect(quarterOf('2020-05-11')).toBe('Q2 2020'); + expect(quarterOf('2020-06-30')).toBe('Q2 2020'); + expect(quarterOf('2020-07-01')).toBe('Q3 2020'); + expect(quarterOf('2020-10-04')).toBe('Q4 2020'); + }); + + it('Correctly formats values to two significant digits', () => { + const values = [ + 0.00000040925, + 0.21, + 0.0000004, + 0.4139877878, + 0.00000234566, + ]; + const formattedValues = values.map(val => + lengthyCurrencyFormatter.format(val), + ); + expect(formattedValues).toEqual([ + '$0.00000041', + '$0.21', + '$0.00000040', + '$0.41', + '$0.0000023', + ]); + }); +}); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts new file mode 100644 index 0000000000..f3790f315e --- /dev/null +++ b/plugins/cost-insights/src/utils/formatters.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 moment from 'moment'; +import { + Duration, + inclusiveEndDateOf, + inclusiveStartDateOf, +} from '../types/Duration'; +import { pluralOf } from '../utils/grammar'; + +export type Period = { + periodStart: string; + periodEnd: string; +}; + +export const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export const lengthyCurrencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + minimumSignificantDigits: 2, + maximumSignificantDigits: 2, +}); + +export const numberFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export const monthFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + month: 'long', + year: 'numeric', +}); + +export const dateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + day: 'numeric', + month: 'short', +}); + +export const monthOf = (date: string): string => { + return monthFormatter.format(Date.parse(date)); +}; + +export const quarterOf = (date: string): string => { + // Supports formatting YYYY-MM-DD and YYYY-[Q]Q returned in alerts + const d = moment(date).isValid() ? moment(date) : moment(date, 'YYYY-[Q]Q'); + return d.format('[Q]Q YYYY'); +}; + +export function formatCurrency(amount: number, currency?: string): string { + const n = Math.round(amount); + const numString = numberFormatter.format(n); + + return currency ? `${numString} ${pluralOf(n, currency)}` : numString; +} + +export function formatPercent(n: number): string { + // Number.toFixed shows scientific notation for extreme numbers + if (isNaN(n) || Math.abs(n) < 0.01) { + return '0%'; + } + if (Math.abs(n) >= 1e19) { + return '∞%'; + } + 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'); + 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'); + return `${start} vs ${end}`; +}; + +export const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/; + +export const formatRelativeDuration = ( + date: string, + duration: Duration, +): string => { + const periodStart = inclusiveStartDateOf(duration); + const periodEnd = inclusiveEndDateOf(duration); + const days = moment.duration(duration).asDays(); + if (![periodStart, periodEnd].includes(date)) { + throw new Error(`Invalid relative date ${date} for duration ${duration}`); + } + return date === periodStart ? `First ${days} Days` : `Last ${days} Days`; +}; + +export function formatDuration(date: string, duration: Duration) { + switch (duration) { + case Duration.P1M: + return monthOf(date); + case Duration.P3M: + return quarterOf(date); + default: + return formatRelativeDuration(date, duration); + } +} diff --git a/plugins/cost-insights/src/utils/grammar.ts b/plugins/cost-insights/src/utils/grammar.ts new file mode 100644 index 0000000000..653c567d0a --- /dev/null +++ b/plugins/cost-insights/src/utils/grammar.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. + */ + +const vowels = { + a: 'A', + e: 'E', + i: 'I', + o: 'O', + u: 'U', +}; + +export const pluralOf = ( + n: number, + string: string, + plural?: string, +): string => { + if (n !== 1) { + if (plural) { + return plural; + } + return string.concat('s'); + } + return string; +}; + +export const indefiniteArticleOf = ( + articles: [string, string], + word: string, +) => { + const firstChar = word.charAt(0).toLowerCase(); + return firstChar in vowels + ? `${articles[1]} ${word}` + : `${articles[0]} ${word}`; +}; diff --git a/plugins/cost-insights/src/utils/graphs.tsx b/plugins/cost-insights/src/utils/graphs.tsx new file mode 100644 index 0000000000..5200b14b1f --- /dev/null +++ b/plugins/cost-insights/src/utils/graphs.tsx @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + currencyFormatter, + dateFormatter, + lengthyCurrencyFormatter, +} from './formatters'; + +export function formatGraphValue(value: number) { + if (value < 1) { + return lengthyCurrencyFormatter.format(value); + } + return currencyFormatter.format(value); +} + +export const overviewGraphTickFormatter = (millis: string | number) => + typeof millis === 'number' ? dateFormatter.format(millis) : millis; diff --git a/plugins/cost-insights/src/utils/history.ts b/plugins/cost-insights/src/utils/history.ts new file mode 100644 index 0000000000..71e8e2c219 --- /dev/null +++ b/plugins/cost-insights/src/utils/history.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 qs from 'qs'; +import { QueryParams } from '../types'; + +export const stringify = (queryParams: Partial) => + qs.stringify(queryParams, { strictNullHandling: true }); +export const parse = (queryString: string): Partial => + qs.parse(queryString, { ignoreQueryPrefix: true, strictNullHandling: true }); diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts new file mode 100644 index 0000000000..87dbcf8158 --- /dev/null +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -0,0 +1,194 @@ +/* + * 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 { + AlertType, + Entity, + ProjectGrowthAlert, + Product, + UnlabeledDataflowAlert, + UnlabeledDataflowAlertProject, + getDefaultState, + DefaultLoadingAction, + Duration, + ProductCost, + findAlways, +} from '../types'; +import { Config } from '@backstage/config'; +import { ConfigApi } from '@backstage/core'; + +type mockAlertRenderer = (alert: T) => T; +type mockEntityRenderer = (entity: T) => T; + +export const createMockEntity = ( + callback?: mockEntityRenderer, +): Entity => { + const defaultEntity: Entity = { + id: 'test-entity', + aggregation: [100, 200], + }; + + if (typeof callback === 'function') { + return callback({ ...defaultEntity }); + } + return { ...defaultEntity }; +}; + +export const createMockProduct = ( + callback?: mockEntityRenderer, +): Product => { + const defaultProduct: Product = { + kind: 'compute-engine', + name: 'Compute Engine', + }; + if (typeof callback === 'function') { + return callback({ ...defaultProduct }); + } + 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, + project: 'test-project-growth-alert', + periodStart: '2019-10-01', + periodEnd: '2020-03-31', + aggregation: [670532.1, 970502.8], + change: { + ratio: 0.5, + amount: 180000, + }, + products: [], + }; + + if (typeof callback === 'function') { + return callback({ ...defaultAlert }); + } + + return { ...defaultAlert }; +}; + +export const createMockUnlabeledDataflowAlert = ( + callback?: mockAlertRenderer, +): UnlabeledDataflowAlert => { + const defaultAlert: UnlabeledDataflowAlert = { + id: AlertType.UnlabeledDataflow, + periodStart: '2020-05-01', + periodEnd: '2020-06-1', + projects: [], + unlabeledCost: 0, + labeledCost: 0, + }; + + if (typeof callback === 'function') { + return callback({ ...defaultAlert }); + } + + return { ...defaultAlert }; +}; + +export const createMockUnlabeledDataflowAlertProject = ( + callback?: mockEntityRenderer, +): UnlabeledDataflowAlertProject => { + const defaultProject: UnlabeledDataflowAlertProject = { + id: 'test-alert-project', + unlabeledCost: 2000.0, + labeledCost: 3200.0, + }; + if (typeof callback === 'function') { + return callback({ ...defaultProject }); + } + return { ...defaultProject }; +}; + +export const MockProductTypes: Record = { + 'compute-engine': 'Compute Engine', + 'cloud-dataflow': 'Cloud Dataflow', + 'cloud-storage': 'Cloud Storage', + 'big-query': 'Big Query', + 'big-table': 'BigTable', + 'cloud-pub-sub': 'Cloud Pub/Sub', +}; + +export const MockProductFilters = Object.keys( + MockProductTypes, +).map(productType => ({ duration: Duration.P1M, productType })); + +export const MockProducts: Product[] = Object.keys(MockProductTypes).map( + productType => + createMockProduct(() => ({ + kind: productType, + name: MockProductTypes[productType], + })), +); + +export const MockLoadingActions = ([ + DefaultLoadingAction.UserGroups, + DefaultLoadingAction.CostInsightsInitial, + DefaultLoadingAction.CostInsightsPage, +] as string[]).concat(MockProducts.map(product => product.kind)); + +export const mockDefaultState = getDefaultState(MockLoadingActions); + +export const MockComputeEngine = findAlways( + MockProducts, + p => p.kind === 'compute-engine', +); +export const MockCloudDataflow = findAlways( + MockProducts, + p => p.kind === 'cloud-dataflow', +); +export const MockCloudStorage = findAlways( + MockProducts, + p => p.kind === 'cloud-storage', +); +export const MockBigQuery = findAlways( + MockProducts, + p => p.kind === 'big-query', +); +export const MockBigtable = findAlways( + MockProducts, + p => p.kind === 'big-table', +); + +export const MockProductsConfig: Partial = { + keys: () => Object.keys(MockProductTypes), +}; + +export const MockMetricsConfig: Partial = { + getOptionalString: () => 'daily-cost', + keys: () => ['daily-cost'], +}; + +export const MockCostInsightsConfig: Partial = { + getConfig: () => MockProductsConfig as Config, + getOptionalConfig: () => MockMetricsConfig as Config, +}; diff --git a/plugins/cost-insights/src/utils/navigation.tsx b/plugins/cost-insights/src/utils/navigation.tsx new file mode 100644 index 0000000000..d2cb1d20bb --- /dev/null +++ b/plugins/cost-insights/src/utils/navigation.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import MoneyIcon from '@material-ui/icons/MonetizationOn'; +import ActionIcon from '@material-ui/icons/Whatshot'; +import Settings from '@material-ui/icons/Settings'; +import AccountTree from '@material-ui/icons/AccountTree'; +import Storage from '@material-ui/icons/Storage'; +import Search from '@material-ui/icons/Search'; +import CloudQueue from '@material-ui/icons/CloudQueue'; +import School from '@material-ui/icons/School'; +import ViewHeadline from '@material-ui/icons/ViewHeadline'; +import { IconType } from '../types'; + +export enum DefaultNavigation { + CostOverviewCard = 'cost-overview-card', + AlertInsightsHeader = 'alert-insights-header', +} + +export type NavigationItem = { + navigation: string; + icon: JSX.Element; + title: string; +}; + +export const getDefaultNavigationItems = (alerts: number): NavigationItem[] => { + const items = [ + { + navigation: DefaultNavigation.CostOverviewCard, + icon: , + title: 'Cost Overview', + }, + ]; + if (alerts > 0) { + items.push({ + navigation: DefaultNavigation.AlertInsightsHeader, + icon: , + title: 'Action Items', + }); + } + return items; +}; + +export function getIcon(icon?: string): JSX.Element { + switch (icon) { + case IconType.Compute: + return ; + case IconType.Data: + return ; + case IconType.Database: + return ; + case IconType.Storage: + return ; + case IconType.Search: + return ; + case IconType.ML: + return ; + default: + return ; + } +} diff --git a/plugins/cost-insights/src/utils/sort.test.ts b/plugins/cost-insights/src/utils/sort.test.ts new file mode 100644 index 0000000000..2f4ca87c7e --- /dev/null +++ b/plugins/cost-insights/src/utils/sort.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { aggregationSort } from './sort'; + +describe('aggregationSort', () => { + const aggregation = [ + { date: '2020-07-05', amount: 0.250991 }, + { date: '2020-07-06', amount: 0.253284 }, + { date: '2020-07-01', amount: 0.260798 }, + ]; + + it('sorts in ascending order', () => { + const sortedAggregation = aggregation.sort(aggregationSort); + expect(sortedAggregation[0].date).toBe('2020-07-01'); + expect(sortedAggregation[1].date).toBe('2020-07-05'); + expect(sortedAggregation[2].date).toBe('2020-07-06'); + }); +}); diff --git a/plugins/cost-insights/src/utils/sort.ts b/plugins/cost-insights/src/utils/sort.ts new file mode 100644 index 0000000000..944f5fc806 --- /dev/null +++ b/plugins/cost-insights/src/utils/sort.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 { DateAggregation, ResourceData } from '../types'; + +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 new file mode 100644 index 0000000000..e6b38ee719 --- /dev/null +++ b/plugins/cost-insights/src/utils/styles.ts @@ -0,0 +1,455 @@ +/* + * 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 { + createStyles, + darken, + getLuminance, + lighten, + makeStyles, +} from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; +import { CostInsightsTheme, CostInsightsThemeOptions } from '../types'; + +export const costInsightsLightTheme = { + palette: { + blue: '#509AF5', + lightBlue: '#9BF0E1', + darkBlue: '#4101F5', + magenta: '#DC148C', + yellow: '#FFC864', + tooltip: { + background: '#171717', + color: '#DDD', + }, + navigationText: '#b5b5b5', + alertBackground: 'rgba(219, 219, 219, 0.13)', + }, +} as CostInsightsThemeOptions; + +// Higher tonal values from light theme (Google recommends 600 -> 200) +// https://material.io/design/color/the-color-system.html#tools-for-picking-colors +export const costInsightsDarkTheme = { + palette: { + blue: '#77b8f9', + lightBlue: '#d8f9f4', + darkBlue: '#b595fd', + magenta: '#ee93cd', + yellow: '#fff2da', + tooltip: { + background: '#EEE', + color: '#424242', + }, + navigationText: '#b5b5b5', + alertBackground: 'rgba(32, 32, 32, 0.13)', + }, +} as CostInsightsThemeOptions; + +// The opposite of MUI's emphasize function - darken darks, lighten lights +export function brighten(color: string, coefficient = 0.2) { + return getLuminance(color) > 0.5 + ? lighten(color, coefficient) + : darken(color, coefficient); +} + +export const useCostOverviewStyles = (theme: CostInsightsTheme) => ({ + axis: { + fill: theme.palette.text.primary, + }, + container: { + height: 450, + width: 1200, + }, + cartesianGrid: { + stroke: theme.palette.textVerySubtle, + }, + chart: { + margin: { + right: 30, + top: 16, + }, + }, + yAxis: { + width: 75, + }, +}); + +export const useBarChartStyles = (theme: CostInsightsTheme) => ({ + axis: { + fill: theme.palette.text.primary, + }, + barChart: { + margin: { + left: 16, + right: 16, + }, + }, + cartesianGrid: { + stroke: theme.palette.textVerySubtle, + }, + cursor: { + fill: theme.palette.textVerySubtle, + fillOpacity: 0.3, + }, + container: { + height: 400, + width: 1200, + }, + infoIcon: { + marginLeft: 2, + fontSize: '1.25em', + }, + xAxis: { + height: 50, + }, +}); + +export const useBarChartStepperButtonStyles = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + root: { + ...theme.typography.button, + boxSizing: 'border-box', + transition: theme.transitions.create( + ['background-color', 'box-shadow', 'border'], + { + duration: theme.transitions.duration.short, + }, + ), + borderRadius: '50%', + padding: 0, + width: 40, + height: 40, + boxShadow: theme.shadows[6], + '&:active': { + boxShadow: theme.shadows[12], + }, + color: theme.palette.text.primary, + backgroundColor: lighten(theme.palette.background.default, 0.1), + '&:hover': { + backgroundColor: lighten(theme.palette.background.default, 0.2), + textDecoration: 'none', + }, + }, + }), +); + +export const useBarChartLabelStyles = makeStyles(() => + createStyles({ + foreignObject: { + textAlign: 'center', + }, + label: { + display: 'block', + textDecoration: 'none', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + infoIcon: { + marginLeft: 2, + fontSize: '1.25em', + }, + }), +); + +export const useCostInsightsStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + h6Subtle: { + ...theme.typography.h6, + fontWeight: 'normal', + color: theme.palette.textSubtle, + }, + }), +); + +export const useCostInsightsTabsStyles = makeStyles( + (theme: BackstageTheme) => ({ + tabs: { + borderBottom: `1px solid ${theme.palette.textVerySubtle}`, + backgroundColor: brighten(theme.palette.background.default), + padding: theme.spacing(0, 4), + }, + tab: { + minHeight: 68, + minWidth: 180, + padding: theme.spacing(1, 4), + '&:hover': { + color: 'inherit', + backgroundColor: 'inherit', + }, + }, + indicator: { + backgroundColor: theme.palette.navigation.indicator, + height: 4, + }, + tabLabel: { + display: 'flex', + alignItems: 'center', + }, + tabLabelText: { + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightBold, + }, + menuItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + minWidth: 180, + padding: theme.spacing(2, 2), + }, + menuItemSelected: { + backgroundColor: lighten(theme.palette.background.default, 0.3), + }, + }), +); + +export const useAlertCardActionHeaderStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + paddingBottom: theme.spacing(2), + borderRadius: 'unset', + }, + title: { + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightBold, + lineHeight: 1.5, + }, + action: { + margin: 0, + }, + }), +); + +export const useCostGrowthStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + excess: { + color: theme.palette.status.error, + }, + savings: { + color: theme.palette.status.ok, + }, + }), +); + +export const useCostGrowthLegendStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + h5: { + ...theme.typography.h5, + fontWeight: 500, + padding: 0, + }, + marker: { + display: 'inherit', + marginRight: theme.spacing(1), + }, + helpIcon: { + display: 'inherit', + }, + title: { + ...theme.typography.overline, + fontWeight: 500, + lineHeight: 0, + marginRight: theme.spacing(1), + color: theme.palette.textSubtle, + }, + tooltip: { + display: 'block', + padding: theme.spacing(1), + backgroundColor: theme.palette.navigation.background, + }, + tooltipText: { + color: theme.palette.background.default, + fontSize: theme.typography.fontSize, + lineHeight: 1.5, + }, + }), +); + +export const useBarChartStepperStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + paper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + background: 'transparent', + padding: 8, + }, + step: { + backgroundColor: theme.palette.action.disabled, + borderRadius: '50%', + width: 9, + height: 9, + margin: '0 2px', + }, + stepActive: { + backgroundColor: theme.palette.primary.main, + }, + steps: { + display: 'flex', + flexDirection: 'row', + }, + backButton: { + position: 'absolute', + left: 0, + top: 'calc(50% - 60px)', + zIndex: 100, + }, + nextButton: { + position: 'absolute', + right: 0, + top: 'calc(50% - 60px)', + zIndex: 100, + }, + }), +); + +export const useNavigationStyles = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + menuList: { + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.navigation.background, + }, + menuItem: { + background: 'transparent', + border: 0, + textTransform: 'none', + width: '100%', + minHeight: theme.spacing(6), + margin: theme.spacing(0.5, 2, 0.5, 0), + }, + listItemIcon: { + minWidth: 40, + }, + navigationIcon: { + fill: theme.palette.navigationText, + }, + title: { + whiteSpace: 'nowrap', + lineHeight: 1, + color: theme.palette.navigationText, + fontWeight: theme.typography.fontWeightBold, + }, + }), +); + +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, + }, + lensIcon: { + fontSize: `.75rem`, + }, + }), +); + +export const useAlertInsightsSectionStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + button: { + backgroundColor: theme.palette.textVerySubtle, + color: theme.palette.text.primary, + }, + }), +); + +export const useSelectStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + select: { + minWidth: 200, + textAlign: 'start', + }, + menuItem: { + minWidth: 200, + padding: theme.spacing(2), + '&.compact': { + padding: theme.spacing(1, 2), + }, + }, + }), +); + +export const useAlertActionCardStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + card: { + boxShadow: 'none', + }, + avatar: { + backgroundColor: theme.palette.textVerySubtle, + color: theme.palette.text.primary, + }, + }), +); + +export const useAlertActionCardHeader = makeStyles( + (theme: CostInsightsTheme) => + createStyles({ + root: { + paddingBottom: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + cursor: 'pointer', + transition: theme.transitions.create('background', { + duration: theme.transitions.duration.short, + }), + '&:hover': { + background: theme.palette.alertBackground, + }, + }, + action: { + margin: 0, + }, + title: { + fontSize: theme.typography.fontSize, + fontWeight: theme.typography.fontWeightBold, + }, + }), +); + +export const useProductInsightsCardStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + padding: theme.spacing(2, 2, 2, 2.5), // restore backstage default padding + }, + action: { + margin: 0, // unset negative margins + }, + }), +); + +export const useBackdropStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + zIndex: theme.zIndex.modal, + }, + }), +); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx new file mode 100644 index 0000000000..807232555b --- /dev/null +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -0,0 +1,86 @@ +/* + * 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, { Dispatch, ReactNode, SetStateAction } from 'react'; +import { + getDefaultPageFilters, + PageFilters, + ProductFilters, + Group, +} from '../types'; +import { FilterContext } 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'; + +export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }]; + +type MockFilterProviderProps = { + setPageFilters: Dispatch>; + setProductFilters: Dispatch>; + children: ReactNode; +}; + +export const MockFilterProvider = ({ + setPageFilters, + setProductFilters, + children, +}: MockFilterProviderProps) => { + const pageFilters = getDefaultPageFilters(MockGroups); + return ( + + {children} + + ); +}; + +export const MockConfigProvider = ({ + metrics, + products, + icons, + engineerCost, + currencies, + children, +}: ConfigContextProps & { children: React.ReactNode }) => ( + + {children} + +); + +export const MockCurrencyProvider = ({ + currency, + setCurrency, + children, +}: CurrencyContextProps & { children: React.ReactNode }) => ( + + {children} + +); + +export const MockScrollProvider = ({ children }: ScrollProviderProps) => ( + + {children} + +); diff --git a/plugins/explore/package.json b/plugins/explore/package.json index c889f96153..5bd78ab088 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "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.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@backstage/core": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,15 +33,17 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", - "@backstage/test-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/test-utils": "^0.1.1-alpha.24", "@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" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" }, "files": [ "dist" diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/components/ExplorePluginPage.tsx index 4365157f62..83402d8062 100644 --- a/plugins/explore/src/components/ExplorePluginPage.tsx +++ b/plugins/explore/src/components/ExplorePluginPage.tsx @@ -79,6 +79,13 @@ const toolsCards = [ 'https://storage.googleapis.com/wf-blogs-engineering-media/2018/09/fe13bb32-wf-tech-radar-hero-1024x597.png', tags: ['standards', 'landscape'], }, + { + title: 'Cost Insights', + description: 'Insights into cloud costs for your organization.', + url: '/cost-insights', + image: 'https://cloud.google.com/images/press/logo-cloud.png', + tags: ['cloud', 'finops'], + }, { title: 'GraphiQL', description: diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index b71cebf6e4..18a957d701 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -1,10 +1,9 @@ { "name": "@backstage/plugin-gcp-projects", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -21,8 +20,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@backstage/core": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -32,14 +31,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", "@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" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" }, "files": [ "dist" diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index 24bc96937f..07a69cdfad 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -11,7 +11,7 @@ TBD ### Generic Requirements 1. Provide OAuth credentials: - 1. [Create an OAuth App](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) with callback URL set to `https://localhost:3000/auth/github`. + 1. [Create an OAuth App](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) with callback URL set to `http://localhost:7000/api/auth/github`. 2. Take Client ID and Client Secret from the newly created app's settings page and put them into `AUTH_GITHUB_CLIENT_ID` and `AUTH_GITHUB_CLIENT_SECRET` env variables. 2. Annotate your component with a correct GitHub Actions repository and owner: diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index c4a6d8cc01..7e2f4e09c3 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.22", + "version": "0.1.1-alpha.24", "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.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.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", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -40,14 +40,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", "@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" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" }, "files": [ "dist", diff --git a/plugins/github-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index f963be7569..5ba1e3880f 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -60,7 +60,10 @@ const WidgetContent = ({ metadata={{ status: ( <> - + ), message: lastRun.message, diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx new file mode 100644 index 0000000000..1d6001e038 --- /dev/null +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 { Props as RecentWorkflowRunsCardProps } from './RecentWorkflowRunsCard'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core-api'; +import { useWorkflowRuns } from '../useWorkflowRuns'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import { MemoryRouter } from 'react-router'; + +jest.mock('../useWorkflowRuns', () => ({ + useWorkflowRuns: jest.fn(), +})); + +const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), +}; + +describe('', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'github.com/project-slug': 'theorg/the-service', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + const workflowRuns = [1, 2, 3, 4, 5].map(n => ({ + id: `run-id-${n}`, + message: `Commit message for workflow ${n}`, + source: { branchName: `branch-${n}` }, + status: 'completed', + })); + + beforeEach(() => { + (useWorkflowRuns as jest.Mock).mockReturnValue([{ runs: workflowRuns }]); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const renderSubject = (props: RecentWorkflowRunsCardProps = { entity }) => + render( + + + + + + + , + ); + + it('renders a table with a row for each workflow', async () => { + const subject = renderSubject(); + + workflowRuns.forEach(run => { + expect(subject.getByText(run.message)).toBeInTheDocument(); + }); + }); + + it('renders a workflow row correctly', async () => { + const subject = renderSubject(); + const [run] = workflowRuns; + expect(subject.getByText(run.message).closest('a')).toHaveAttribute( + 'href', + `/ci-cd/${run.id}`, + ); + expect(subject.getByText(run.source.branchName)).toBeInTheDocument(); + }); + + it('requests only the required number of workflow runs', async () => { + const limit = 3; + renderSubject({ entity, limit }); + expect(useWorkflowRuns).toHaveBeenCalledWith( + expect.objectContaining({ initialPageSize: limit }), + ); + }); + + it('uses the github repo and owner from the entity annotation', async () => { + renderSubject(); + expect(useWorkflowRuns).toHaveBeenCalledWith( + expect.objectContaining({ owner: 'theorg', repo: 'the-service' }), + ); + }); + + it('filters workflows by branch if one is specified', async () => { + const branch = 'master'; + renderSubject({ entity, branch }); + expect(useWorkflowRuns).toHaveBeenCalledWith( + expect.objectContaining({ branch }), + ); + }); + + describe('where there is an error fetching workflows', () => { + const error = 'error getting workflows'; + beforeEach(() => { + (useWorkflowRuns as jest.Mock).mockReturnValue([{ runs: [], error }]); + }); + + it('sends the error to the errorApi', async () => { + renderSubject(); + expect(mockErrorApi.post).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx new file mode 100644 index 0000000000..e5bba7fad9 --- /dev/null +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -0,0 +1,103 @@ +/* + * 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 { 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 { WorkflowRunStatus } from '../WorkflowRunStatus'; +import { Button, Card, Link, TableContainer } from '@material-ui/core'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; + +const firstLine = (message: string): string => message.split('\n')[0]; + +export type Props = { + entity: Entity; + branch?: string; + dense?: boolean; + limit?: number; +}; + +export const RecentWorkflowRunsCard = ({ + entity, + branch, + dense = false, + limit = 5, +}: Props) => { + const errorApi = useApi(errorApiRef); + const [owner, repo] = ( + entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/' + ).split('/'); + const [{ runs = [], loading, error }] = useWorkflowRuns({ + owner, + repo, + branch, + initialPageSize: limit, + }); + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + + return !runs.length ? ( + + Create new Workflow + + } + /> + ) : ( + +
( + + {firstLine(data.message)} + + ), + }, + { title: 'Branch', field: 'source.branchName' }, + { title: 'Status', field: 'status', render: WorkflowRunStatus }, + ]} + data={runs} + /> + + ); +}; diff --git a/plugins/github-actions/src/components/Cards/index.ts b/plugins/github-actions/src/components/Cards/index.ts index 8c987ea1d5..ab918d3b77 100644 --- a/plugins/github-actions/src/components/Cards/index.ts +++ b/plugins/github-actions/src/components/Cards/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards'; +export { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard'; diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx index 8314981ed6..720f751be6 100644 --- a/plugins/github-actions/src/components/Router.tsx +++ b/plugins/github-actions/src/components/Router.tsx @@ -20,19 +20,14 @@ import { rootRouteRef, buildRouteRef } from '../plugin'; import { WorkflowRunDetails } from './WorkflowRunDetails'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { GITHUB_ACTIONS_ANNOTATION } from '../../universal'; -import { WarningPanel } from '@backstage/core'; +import { MissingAnnotationEmptyState } from '@backstage/core'; export const isPluginApplicableToEntity = (entity: Entity) => - Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]) && - entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] !== ''; + Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]); export const Router = ({ entity }: { entity: Entity }) => - // TODO(shmidt-i): move warning to a separate standardized component !isPluginApplicableToEntity(entity) ? ( - -
{GITHUB_ACTIONS_ANNOTATION}
annotation is missing on the - entity. -
+ ) : ( { /> - + ); @@ -204,7 +207,10 @@ export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { Status - + diff --git a/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx b/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx index 089c319802..b1a71ea3ed 100644 --- a/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx +++ b/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx @@ -14,13 +14,22 @@ * limitations under the License. */ -import { StatusPending, StatusRunning, StatusOK } from '@backstage/core'; +import { + StatusPending, + StatusRunning, + StatusOK, + StatusWarning, + StatusAborted, + StatusError, +} from '@backstage/core'; import React from 'react'; export const WorkflowRunStatus = ({ status, + conclusion, }: { status: string | undefined; + conclusion: string | undefined; }) => { if (status === undefined) return null; switch (status.toLowerCase()) { @@ -37,11 +46,32 @@ export const WorkflowRunStatus = ({ ); case 'completed': - return ( - <> - Completed - - ); + switch (conclusion?.toLowerCase()) { + case 'skipped' || 'canceled': + return ( + <> + Aborted + + ); + case 'timed_out': + return ( + <> + Timed out + + ); + case 'failure': + return ( + <> + Error + + ); + default: + return ( + <> + Completed + + ); + } default: return ( <> diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index cc834f09ee..fcde5fefbb 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -14,11 +14,18 @@ * limitations under the License. */ import React, { FC } from 'react'; -import { Link, Typography, Box, IconButton, Tooltip } from '@material-ui/core'; +import { + Link, + Typography, + Box, + IconButton, + Tooltip, + Button, +} from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; import { Link as RouterLink, generatePath } from 'react-router-dom'; -import { Table, TableColumn } from '@backstage/core'; +import { EmptyState, Table, TableColumn } from '@backstage/core'; import { useWorkflowRuns } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; @@ -39,6 +46,7 @@ export type WorkflowRun = { }; }; status: string; + conclusion: string; onReRunClick: () => void; }; @@ -77,7 +85,7 @@ const generatedColumns: TableColumn[] = [ render: (row: Partial) => ( - + ), }, @@ -156,15 +164,34 @@ export const WorkflowRunsTable = ({ }) => { const { value: projectName, loading } = useProjectName(entity); const [owner, repo] = (projectName ?? '/').split('/'); - const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns({ + const [ + { runs, ...tableProps }, + { retry, setPage, setPageSize }, + ] = useWorkflowRuns({ owner, repo, branch, }); - return ( + return !runs ? ( + + Create new Workflow + + } + /> + ) : ( - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index 9ba972bd22..edd3f2782e 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.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,10 +19,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.22", - "@backstage/plugin-catalog-graphql": "^0.1.1-alpha.22", + "@backstage/backend-common": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", + "@backstage/plugin-catalog-graphql": "^0.1.1-alpha.24", "@graphql-modules/core": "^0.7.17", - "@backstage/backend-common": "^0.1.1-alpha.22", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", "apollo-server-express": "^2.16.1", @@ -36,7 +36,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/identity-backend/README.md b/plugins/identity-backend/README.md deleted file mode 100644 index c95fed0698..0000000000 --- a/plugins/identity-backend/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Identity Backend - -WORK IN PROGRESS - -This is the backend part of the identity plugin. - -It responds to identity requests from the frontend. - -## Links - -- [The Backstage homepage](https://backstage.io) diff --git a/plugins/identity-backend/src/adapters/StaticJsonAdapter.ts b/plugins/identity-backend/src/adapters/StaticJsonAdapter.ts deleted file mode 100644 index 9a58da2e85..0000000000 --- a/plugins/identity-backend/src/adapters/StaticJsonAdapter.ts +++ /dev/null @@ -1,82 +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 { - Group, - GroupsResponse, - IdentityApi, - GroupsRequest, - GroupsJson, -} from './types'; - -export class StaticJsonAdapter implements IdentityApi { - private readonly groups: Group[]; - - constructor(userGroups: GroupsJson) { - this.groups = userGroups.groups; - } - - getUserGroups(req: GroupsRequest): Promise { - return new Promise(resolve => { - const { user, type } = req; - const userGroups = this._getUserGroups(this.groups, user); - const groups = this.filterGroupsByType(userGroups, type); - resolve({ groups }); - }); - } - - _getUserGroups(groups: Group[], user: string) { - const userGroups: Set = new Set(); - groups.forEach(group => { - if (this.isUserInGroup(group, user)) { - userGroups.add(group); - } - - if (group.children) { - const userSubGroups = this._getUserGroups(group.children, user) ?? []; - const isUserInSubGroup = Boolean(userSubGroups.length); - if (isUserInSubGroup) { - userGroups.add(group); - } - userSubGroups.forEach(subGroup => userGroups.add(subGroup)); - } - }); - return Array.from(userGroups); - } - - private filterGroupsByType = (userGroups: Group[], type: string) => { - const groups = type - ? userGroups - .filter((group: Group) => group.type === type) - .map(group => ({ name: group.name, type: group.type })) - : userGroups.map(group => ({ - name: group.name, - type: group.type, - })); - return groups; - }; - - private isUserInGroup = (group: Group, user: string): boolean => { - if (group.members) { - const groupMembers = group.members; - const groupsWithUser = groupMembers.filter( - member => member.name === user, - ); - return Boolean(groupsWithUser.length); - } - return false; - }; -} diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 8f0d43cba7..565906ca85 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -30,10 +30,7 @@ proxy: changeOrigin: true headers: Authorization: - $secret: - env: JENKINS_BASIC_AUTH_HEADER - pathRewrite: - '^/proxy/jenkins/api/': '/' + $env: JENKINS_BASIC_AUTH_HEADER ``` 4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password) diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 398161f270..186fdbcc33 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "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.22", - "@backstage/core": "^0.1.1-alpha.22", - "@backstage/plugin-catalog": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@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", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,15 +36,17 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", "@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" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" }, "files": [ "dist" diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts index 2b95fe97d9..0fa48a1d17 100644 --- a/plugins/jenkins/src/api/index.ts +++ b/plugins/jenkins/src/api/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '@backstage/core'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; const jenkins = require('jenkins'); @@ -24,28 +24,42 @@ export const jenkinsApiRef = createApiRef({ description: 'Used by the Jenkins plugin to make requests', }); -export class JenkinsApi { - apiUrl: string; - jenkins: any; +const DEFAULT_PROXY_PATH = '/jenkins/api'; - constructor(apiUrl: string) { - this.apiUrl = apiUrl; - this.jenkins = jenkins({ baseUrl: apiUrl, promisify: true }); +type Options = { + discoveryApi: DiscoveryApi; + /** + * Path to use for requests via the proxy, defaults to /jenkins/api + */ + proxyPath?: string; +}; + +export class JenkinsApi { + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPath: string; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH; + } + + private async getClient() { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return jenkins({ baseUrl: proxyUrl + this.proxyPath, promisify: true }); } async retry(buildName: string) { + const client = await this.getClient(); // looks like the current SDK only supports triggering a new build // can't see any support for replay (re-running the specific build with the same SCM info) - return await this.jenkins.job.build(buildName); + return await client.job.build(buildName); } async getLastBuild(jobName: string) { - const job = await this.jenkins.job.get(jobName); + const client = await this.getClient(); + const job = await client.job.get(jobName); - const lastBuild = await this.jenkins.build.get( - jobName, - job.lastBuild.number, - ); + const lastBuild = await client.build.get(jobName, job.lastBuild.number); return lastBuild; } @@ -84,17 +98,19 @@ export class JenkinsApi { } async getJob(jobName: string) { - return this.jenkins.job.get({ + const client = await this.getClient(); + return client.job.get({ name: jobName, depth: 1, }); } async getFolder(folderName: string) { - const folder = await this.jenkins.job.get(folderName); + const client = await this.getClient(); + const folder = await client.job.get(folderName); const results = []; for (const jobSummary of folder.jobs) { - const jobDetails = await this.jenkins.job.get({ + const jobDetails = await client.job.get({ name: `${folderName}/${jobSummary.name}`, depth: 1, }); @@ -104,7 +120,7 @@ export class JenkinsApi { // skipping folders inside folders for now } else { for (const buildDetails of jobDetails.builds) { - const build = await this.jenkins.build.get({ + const build = await client.build.get({ name: `${folderName}/${jobSummary.name}`, number: buildDetails.number, depth: 1, @@ -191,10 +207,11 @@ export class JenkinsApi { } async getBuild(buildName: string) { + const client = await this.getClient(); const { jobName, buildNumber } = this.extractJobDetailsFromBuildName( buildName, ); - const buildResult = await this.jenkins.build.get(jobName, buildNumber); + const buildResult = await client.build.get(jobName, buildNumber); return buildResult; } diff --git a/plugins/jenkins/src/assets/build-details.png b/plugins/jenkins/src/assets/build-details.png index ee463fea43..a333fb6611 100644 Binary files a/plugins/jenkins/src/assets/build-details.png and b/plugins/jenkins/src/assets/build-details.png differ diff --git a/plugins/jenkins/src/assets/folder-results.png b/plugins/jenkins/src/assets/folder-results.png index c01e4cf437..2c14d5d927 100644 Binary files a/plugins/jenkins/src/assets/folder-results.png and b/plugins/jenkins/src/assets/folder-results.png differ diff --git a/plugins/jenkins/src/assets/last-master-build.png b/plugins/jenkins/src/assets/last-master-build.png index 517e7a4410..4174f08997 100644 Binary files a/plugins/jenkins/src/assets/last-master-build.png and b/plugins/jenkins/src/assets/last-master-build.png differ diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 282cbc59b5..23f9af445c 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -109,20 +109,27 @@ const generatedColumns: TableColumn[] = [ title: 'Build', field: 'buildName', highlight: true, - render: (row: Partial) => ( - - {row.buildName} - - ), + render: (row: Partial) => { + if (!row.source?.branchName || !row.buildNumber) { + return <>{row.buildName}; + } + + return ( + + {row.buildName} + + ); + }, }, { title: 'Source', + field: 'source.branchName', render: (row: Partial) => ( <>

@@ -136,6 +143,7 @@ const generatedColumns: TableColumn[] = [ }, { title: 'Status', + field: 'status', render: (row: Partial) => { return ( @@ -146,6 +154,7 @@ const generatedColumns: TableColumn[] = [ }, { title: 'Tests', + sorting: false, render: (row: Partial) => { return ( <> @@ -168,6 +177,7 @@ const generatedColumns: TableColumn[] = [ }, { title: 'Actions', + sorting: false, render: (row: Partial) => ( diff --git a/plugins/jenkins/src/components/Router.tsx b/plugins/jenkins/src/components/Router.tsx index 56df5456bc..a4c7314691 100644 --- a/plugins/jenkins/src/components/Router.tsx +++ b/plugins/jenkins/src/components/Router.tsx @@ -23,8 +23,7 @@ import { WarningPanel } from '@backstage/core'; import { CITable } from './BuildsPage/lib/CITable'; export const isPluginApplicableToEntity = (entity: Entity) => - Boolean(entity.metadata.annotations?.[JENKINS_ANNOTATION]) && - entity.metadata.annotations?.[JENKINS_ANNOTATION] !== ''; + Boolean(entity.metadata.annotations?.[JENKINS_ANNOTATION]); export const Router = ({ entity }: { entity: Entity }) => { return !isPluginApplicableToEntity(entity) ? ( diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 8282f6b7f9..23c54743a3 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -18,7 +18,7 @@ import { createPlugin, createRouteRef, createApiFactory, - configApiRef, + discoveryApiRef, } from '@backstage/core'; import { jenkinsApiRef, JenkinsApi } from './api'; @@ -37,11 +37,8 @@ export const plugin = createPlugin({ apis: [ createApiFactory({ api: jenkinsApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => - new JenkinsApi( - `${configApi.getString('backend.baseUrl')}/proxy/jenkins/api`, - ), + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new JenkinsApi({ discoveryApi }), }), ], }); diff --git a/plugins/kubernetes-backend/.eslintrc.js b/plugins/kubernetes-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/kubernetes-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/kubernetes-backend/README.md b/plugins/kubernetes-backend/README.md new file mode 100644 index 0000000000..0246da24ed --- /dev/null +++ b/plugins/kubernetes-backend/README.md @@ -0,0 +1,11 @@ +# Kubernetes Backend + +WORK IN PROGRESS + +This is the backend part of the Kubernetes plugin. + +It responds to Kubernetes requests from the frontend. + +## Links + +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/kubernetes-backend/examples/dice-roller/README.md b/plugins/kubernetes-backend/examples/dice-roller/README.md new file mode 100644 index 0000000000..47fc345c73 --- /dev/null +++ b/plugins/kubernetes-backend/examples/dice-roller/README.md @@ -0,0 +1,44 @@ +# Dice roller + +An app to roll dice (it doesn't actually do that). + +# Viewing in local Minikube running Backstage locally + +## Prerequisites + +- kubectl installed +- Minikube installed, with the following addons + - metrics-server + - ingress +- jq installed +- Backstage locally built and ready to run + +## Steps + +1. Start minikube +2. Get the Kubernetes master base url `kubectl cluster-info` +3. Apply manifests `kubectl apply -f dice-roller-manifests.yaml` +4. Get service account token (see below) +5. Start Backstage UI and backend +6. Register existing component in Backstage + - https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml + +Update `app-config.yaml` as follows. + +```yaml +--- +kubernetes: + clusterLocatorMethod: 'configMultiTenant' + clusters: + - url: + name: minikube + serviceAccountToken: +``` + +### Getting the service account token + +``` +kubectl get secret DICE_ROLLER_TOKEN_NAME -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy +``` + +Paste into `app-config.yaml` `kubernetes.clusters[].serviceAccountToken` diff --git a/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml b/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml new file mode 100644 index 0000000000..a944232224 --- /dev/null +++ b/plugins/kubernetes-backend/examples/dice-roller/catalog-info.yaml @@ -0,0 +1,13 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: dice-roller + description: It rolls dice + tags: + - go + annotations: + 'backstage.io/kubernetes-id': dice-roller +spec: + type: service + lifecycle: production + owner: guest diff --git a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml new file mode 100644 index 0000000000..50956bd7d3 --- /dev/null +++ b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml @@ -0,0 +1,166 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller + replicas: 10 + template: + metadata: + labels: + app: dice-roller + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller-canary + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller-canary + replicas: 2 + template: + metadata: + labels: + app: dice-roller-canary + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 + - name: side-car + image: nginx:1.14.2 + ports: + - containerPort: 81 + - name: other-side-car + image: nginx:1.14.2 + ports: + - containerPort: 82 + +--- +apiVersion: autoscaling/v1 +kind: HorizontalPodAutoscaler +metadata: + name: dice-roller + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: dice-roller + minReplicas: 10 + maxReplicas: 15 + targetCPUUtilizationPercentage: 50 + +--- +apiVersion: networking.k8s.io/v1beta1 +kind: Ingress +metadata: + name: dice-roller + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$1 + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + rules: + - host: nginx + http: + paths: + - path: / + backend: + serviceName: dice-roller + servicePort: 80 + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: dice-roller + namespace: default + labels: + 'backstage.io/kubernetes-id': dice-roller +data: + foo: bar + +--- +apiVersion: v1 +kind: Secret +metadata: + name: dice-roller + labels: + 'backstage.io/kubernetes-id': dice-roller +type: Opaque +data: + username: YWRtaW4= +--- +apiVersion: v1 +kind: Service +metadata: + name: dice-roller + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + app: dice-roller + ports: + - protocol: TCP + port: 80 + targetPort: 9376 + name: port1 + - protocol: TCP + port: 81 + targetPort: 9377 + name: port2 + +--- +apiVersion: v1 +kind: Service +metadata: + name: dice-roller-lb + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + app: dice-roller + ports: + - port: 8765 + targetPort: 9376 + type: LoadBalancer + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: dice-roller +automountServiceAccountToken: false + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: sa-admin +roleRef: + kind: ClusterRole + name: cluster-admin + apiGroup: rbac.authorization.k8s.io +subjects: + - kind: ServiceAccount + name: dice-roller + namespace: default diff --git a/plugins/identity-backend/package.json b/plugins/kubernetes-backend/package.json similarity index 69% rename from plugins/identity-backend/package.json rename to plugins/kubernetes-backend/package.json index a34049fb91..a44199ec8b 100644 --- a/plugins/identity-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { - "name": "@backstage/plugin-identity-backend", - "version": "0.1.1-alpha.22", + "name": "@backstage/plugin-kubernetes-backend", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,7 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.22", + "@backstage/backend-common": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", + "@kubernetes/client-node": "^0.12.1", "@types/express": "^4.17.6", "compression": "^1.7.4", "cors": "^2.8.5", @@ -28,13 +30,16 @@ "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", "helmet": "^4.0.0", + "lodash": "^4.17.15", "morgan": "^1.10.0", + "stream-buffers": "^3.0.2", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "jest-fetch-mock": "^3.0.3" + "@backstage/cli": "^0.1.1-alpha.24", + "jest-fetch-mock": "^3.0.3", + "supertest": "^4.0.2" }, "files": [ "dist" diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.ts new file mode 100644 index 0000000000..dd77a11bae --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.test.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 '@backstage/backend-common'; +import { MultiTenantConfigClusterLocator } from './MultiTenantConfigClusterLocator'; +import { ConfigReader, Config } from '@backstage/config'; + +describe('MultiTenantConfigClusterLocator', () => { + it('empty clusters returns empty cluster details', async () => { + const config: Config = new ConfigReader( + { + clusters: [], + }, + 'ctx', + ); + + const sut = MultiTenantConfigClusterLocator.fromConfig( + config.getConfigArray('clusters'), + ); + + const result = await sut.getClusterByServiceId('ignored'); + + expect(result).toStrictEqual([]); + }); + + it('one clusters returns one cluster details', async () => { + const config: Config = new ConfigReader( + { + clusters: [ + { + name: 'cluster1', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + ], + }, + 'ctx', + ); + + const sut = MultiTenantConfigClusterLocator.fromConfig( + config.getConfigArray('clusters'), + ); + + const result = await sut.getClusterByServiceId('ignored'); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: undefined, + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + ]); + }); + + it('two clusters returns two cluster details', async () => { + const config: Config = new ConfigReader( + { + clusters: [ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + url: 'http://localhost:8081', + authProvider: 'google', + }, + ], + }, + 'ctx', + ); + + const sut = MultiTenantConfigClusterLocator.fromConfig( + config.getConfigArray('clusters'), + ); + + const result = await sut.getClusterByServiceId('ignored'); + + expect(result).toStrictEqual([ + { + name: 'cluster1', + serviceAccountToken: 'token', + url: 'http://localhost:8080', + authProvider: 'serviceAccount', + }, + { + name: 'cluster2', + serviceAccountToken: undefined, + url: 'http://localhost:8081', + authProvider: 'google', + }, + ]); + }); +}); diff --git a/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.ts new file mode 100644 index 0000000000..5a1f0f3839 --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/MultiTenantConfigClusterLocator.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 { 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 { + private readonly clusterDetails: ClusterDetails[]; + + constructor(clusterDetails: ClusterDetails[]) { + this.clusterDetails = clusterDetails; + } + + static fromConfig(config: Config[]): MultiTenantConfigClusterLocator { + // TODO: Add validation that authProvider is required and serviceAccountToken + // is required if authProvider is serviceAccount + return new MultiTenantConfigClusterLocator( + config.map(c => { + return { + name: c.getString('name'), + url: c.getString('url'), + serviceAccountToken: c.getOptionalString('serviceAccountToken'), + authProvider: c.getString('authProvider'), + }; + }), + ); + } + + // 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 { + return this.clusterDetails; + } +} diff --git a/plugins/kubernetes-backend/src/cluster-locator/types.ts b/plugins/kubernetes-backend/src/cluster-locator/types.ts new file mode 100644 index 0000000000..dae8eb6d60 --- /dev/null +++ b/plugins/kubernetes-backend/src/cluster-locator/types.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 type ClusterLocatorMethod = 'configMultiTenant' | 'http'; +export type AuthProviderType = 'google' | 'serviceAccount'; diff --git a/plugins/identity-backend/src/index.test.ts b/plugins/kubernetes-backend/src/index.test.ts similarity index 100% rename from plugins/identity-backend/src/index.test.ts rename to plugins/kubernetes-backend/src/index.test.ts diff --git a/plugins/kubernetes-backend/src/index.ts b/plugins/kubernetes-backend/src/index.ts new file mode 100644 index 0000000000..96f070ff1e --- /dev/null +++ b/plugins/kubernetes-backend/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 './service/router'; +export * from './types/types'; diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..7f9b2f0bae --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/GoogleKubernetesAuthTranslator.ts @@ -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 { KubernetesAuthTranslator } from './types'; +import { AuthRequestBody, ClusterDetails } from '../types/types'; + +export class GoogleKubernetesAuthTranslator + implements KubernetesAuthTranslator { + async decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + requestBody: AuthRequestBody, + ): Promise { + const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( + {}, + clusterDetails, + ); + const authToken: string | undefined = requestBody.auth?.google; + + if (authToken) { + clusterDetailsWithAuthToken.serviceAccountToken = authToken; + } else { + throw new Error( + 'Google token not found under auth.google in request body', + ); + } + return clusterDetailsWithAuthToken; + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts new file mode 100644 index 0000000000..3b27eb012a --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { KubernetesAuthTranslator } from './types'; +import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; +import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator'; +import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; + +describe('getKubernetesAuthTranslatorInstance', () => { + const sut = KubernetesAuthTranslatorGenerator; + + it('can return an auth translator for google auth', () => { + const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( + 'google', + ); + expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true); + }); + + it('can return an auth translator for serviceAccount auth', () => { + const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( + 'serviceAccount', + ); + expect( + authTranslator instanceof ServiceAccountKubernetesAuthTranslator, + ).toBe(true); + }); + + it('throws an error when asked for an auth translator for an unsupported auth type', () => { + expect(() => sut.getKubernetesAuthTranslatorInstance('linode')).toThrow( + 'authProvider "linode" has no KubernetesAuthTranslator associated with it', + ); + }); +}); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts new file mode 100644 index 0000000000..f7cfc92112 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.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 { KubernetesAuthTranslator } from './types'; +import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; +import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; + +export class KubernetesAuthTranslatorGenerator { + static getKubernetesAuthTranslatorInstance( + authProvider: String, + ): KubernetesAuthTranslator { + switch (authProvider) { + case 'google': { + return new GoogleKubernetesAuthTranslator(); + } + case 'serviceAccount': { + return new ServiceAccountKubernetesAuthTranslator(); + } + default: { + throw new Error( + `authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`, + ); + } + } + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..ecf2f12b72 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/ServiceAccountKubernetesAuthTranslator.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { KubernetesAuthTranslator } from './types'; +import { AuthRequestBody, ClusterDetails } from '../types/types'; + +export class ServiceAccountKubernetesAuthTranslator + implements KubernetesAuthTranslator { + async decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + // To ignore TS6133 linting error where it detects 'requestBody' is declared but its value is never read. + // @ts-ignore-start + requestBody: AuthRequestBody, // eslint-disable-line @typescript-eslint/no-unused-vars + // @ts-ignore-end + ): Promise { + return clusterDetails; + } +} diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts new file mode 100644 index 0000000000..f89e04456f --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/types.ts @@ -0,0 +1,24 @@ +/* + * 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 { AuthRequestBody, ClusterDetails } from '../types/types'; + +export interface KubernetesAuthTranslator { + decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + requestBody: AuthRequestBody, + ): Promise; +} diff --git a/plugins/identity-backend/src/run.ts b/plugins/kubernetes-backend/src/run.ts similarity index 98% rename from plugins/identity-backend/src/run.ts rename to plugins/kubernetes-backend/src/run.ts index a2c2601258..995adaff10 100644 --- a/plugins/identity-backend/src/run.ts +++ b/plugins/kubernetes-backend/src/run.ts @@ -18,7 +18,7 @@ import yn from 'yn'; import { getRootLogger } from '@backstage/backend-common'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3004; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts new file mode 100644 index 0000000000..409d41b783 --- /dev/null +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { KubernetesClientProvider } from './KubernetesClientProvider'; + +describe('KubernetesClientProvider', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('can get core client by cluster details', async () => { + const sut = new KubernetesClientProvider(); + + const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({})); + + sut.getKubeConfig = mockGetKubeConfig; + + const result = sut.getCoreClientByClusterDetails({ + name: 'cluster-name', + url: 'http://localhost:9999', + serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', + }); + + expect(result.basePath).toBe('http://localhost:9999'); + // These fields aren't on the type but are there + const auth = (result as any).authentications.default; + expect(auth.users[0].token).toBe('TOKEN'); + expect(auth.clusters[0].name).toBe('cluster-name'); + + expect(mockGetKubeConfig.mock.calls.length).toBe(1); + }); + + it('can get cached core client by cluster details', async () => { + const sut = new KubernetesClientProvider(); + + const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({})); + + sut.getKubeConfig = mockGetKubeConfig; + + const result1 = sut.getCoreClientByClusterDetails({ + name: 'cluster-name', + url: 'http://localhost:9999', + serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', + }); + + const result2 = sut.getCoreClientByClusterDetails({ + name: 'cluster-name', + url: 'http://localhost:9999', + serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', + }); + + expect(result1.basePath).toBe('http://localhost:9999'); + // These fields aren't on the type but are there + const auth1 = (result1 as any).authentications.default; + expect(auth1.users[0].token).toBe('TOKEN'); + expect(auth1.clusters[0].name).toBe('cluster-name'); + + expect(result2.basePath).toBe('http://localhost:9999'); + // These fields aren't on the type but are there + const auth2 = (result2 as any).authentications.default; + expect(auth2.users[0].token).toBe('TOKEN'); + expect(auth2.clusters[0].name).toBe('cluster-name'); + + expect(mockGetKubeConfig.mock.calls.length).toBe(1); + }); + + it('can get apps client by cluster details', async () => { + const sut = new KubernetesClientProvider(); + + const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({})); + + sut.getKubeConfig = mockGetKubeConfig; + + const result = sut.getAppsClientByClusterDetails({ + name: 'cluster-name', + url: 'http://localhost:9999', + serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', + }); + + expect(result.basePath).toBe('http://localhost:9999'); + // These fields aren't on the type but are there + const auth = (result as any).authentications.default; + expect(auth.users[0].token).toBe('TOKEN'); + expect(auth.clusters[0].name).toBe('cluster-name'); + + expect(mockGetKubeConfig.mock.calls.length).toBe(1); + }); + + it('can get cached apps client by cluster details', async () => { + const sut = new KubernetesClientProvider(); + + const mockGetKubeConfig = jest.fn(sut.getKubeConfig.bind({})); + + sut.getKubeConfig = mockGetKubeConfig; + + const result1 = sut.getAppsClientByClusterDetails({ + name: 'cluster-name', + url: 'http://localhost:9999', + serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', + }); + + const result2 = sut.getAppsClientByClusterDetails({ + name: 'cluster-name', + url: 'http://localhost:9999', + serviceAccountToken: 'TOKEN', + authProvider: 'serviceAccount', + }); + + expect(result1.basePath).toBe('http://localhost:9999'); + // These fields aren't on the type but are there + const auth1 = (result1 as any).authentications.default; + expect(auth1.users[0].token).toBe('TOKEN'); + expect(auth1.clusters[0].name).toBe('cluster-name'); + + expect(result2.basePath).toBe('http://localhost:9999'); + // These fields aren't on the type but are there + const auth2 = (result2 as any).authentications.default; + expect(auth2.users[0].token).toBe('TOKEN'); + expect(auth2.clusters[0].name).toBe('cluster-name'); + + expect(mockGetKubeConfig.mock.calls.length).toBe(1); + }); +}); diff --git a/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts new file mode 100644 index 0000000000..8b12d53afc --- /dev/null +++ b/plugins/kubernetes-backend/src/service/KubernetesClientProvider.ts @@ -0,0 +1,144 @@ +/* + * 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 } from '..'; +import { + AppsV1Api, + AutoscalingV1Api, + CoreV1Api, + KubeConfig, + NetworkingV1beta1Api, +} from '@kubernetes/client-node'; + +export class KubernetesClientProvider { + private readonly coreClientMap: { + [key: string]: CoreV1Api; + }; + + private readonly appsClientMap: { + [key: string]: AppsV1Api; + }; + + private readonly autoscalingClientMap: { + [key: string]: AutoscalingV1Api; + }; + + private readonly networkingBeta1ClientMap: { + [key: string]: NetworkingV1beta1Api; + }; + + constructor() { + this.coreClientMap = {}; + this.appsClientMap = {}; + this.autoscalingClientMap = {}; + this.networkingBeta1ClientMap = {}; + } + + // visible for testing + getKubeConfig(clusterDetails: ClusterDetails) { + const cluster = { + name: clusterDetails.name, + server: clusterDetails.url, + // TODO configure this + skipTLSVerify: true, + }; + + // TODO configure + const user = { + name: 'service-account', + token: clusterDetails.serviceAccountToken, + }; + + const context = { + name: `${clusterDetails.name}`, + user: user.name, + cluster: cluster.name, + }; + + const kc = new KubeConfig(); + kc.loadFromOptions({ + clusters: [cluster], + users: [user], + contexts: [context], + currentContext: context.name, + }); + return kc; + } + + getCoreClientByClusterDetails(clusterDetails: ClusterDetails) { + const clientMapKey = clusterDetails.name; + + if (this.coreClientMap.hasOwnProperty(clientMapKey)) { + return this.coreClientMap[clientMapKey]; + } + + const kc = this.getKubeConfig(clusterDetails); + + const client = kc.makeApiClient(CoreV1Api); + + this.coreClientMap[clientMapKey] = client; + + return client; + } + + getAppsClientByClusterDetails(clusterDetails: ClusterDetails) { + const clientMapKey = clusterDetails.name; + + if (this.appsClientMap.hasOwnProperty(clientMapKey)) { + return this.appsClientMap[clientMapKey]; + } + + const kc = this.getKubeConfig(clusterDetails); + + const client = kc.makeApiClient(AppsV1Api); + + this.appsClientMap[clientMapKey] = client; + + return client; + } + + getAutoscalingClientByClusterDetails(clusterDetails: ClusterDetails) { + const clientMapKey = clusterDetails.name; + + if (this.autoscalingClientMap.hasOwnProperty(clientMapKey)) { + return this.autoscalingClientMap[clientMapKey]; + } + + const kc = this.getKubeConfig(clusterDetails); + + const client = kc.makeApiClient(AutoscalingV1Api); + + this.autoscalingClientMap[clientMapKey] = client; + + return client; + } + + getNetworkingBeta1Client(clusterDetails: ClusterDetails) { + const clientMapKey = clusterDetails.name; + + if (this.networkingBeta1ClientMap.hasOwnProperty(clientMapKey)) { + return this.networkingBeta1ClientMap[clientMapKey]; + } + + const kc = this.getKubeConfig(clusterDetails); + + const client = kc.makeApiClient(NetworkingV1beta1Api); + + this.networkingBeta1ClientMap[clientMapKey] = client; + + return client; + } +} diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts new file mode 100644 index 0000000000..6294b3a7ed --- /dev/null +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -0,0 +1,257 @@ +/* + * 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 { KubernetesClientBasedFetcher } from './KubernetesFetcher'; + +describe('KubernetesClientProvider', () => { + let clientMock: any; + let kubernetesClientProvider: any; + let sut: KubernetesClientBasedFetcher; + + beforeEach(() => { + jest.resetAllMocks(); + clientMock = { + listPodForAllNamespaces: jest.fn(), + listServiceForAllNamespaces: jest.fn(), + }; + + kubernetesClientProvider = { + getCoreClientByClusterDetails: jest.fn(() => clientMock), + getAppsClientByClusterDetails: jest.fn(() => clientMock), + getAutoscalingClientByClusterDetails: jest.fn(() => clientMock), + getNetworkingBeta1Client: jest.fn(() => clientMock), + }; + + sut = new KubernetesClientBasedFetcher({ + kubernetesClientProvider, + logger: getVoidLogger(), + }); + }); + + const testErrorResponse = async (errorResponse: any, expectedResult: any) => { + clientMock.listPodForAllNamespaces.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { + name: 'pod-name', + }, + }, + ], + }, + }); + + clientMock.listServiceForAllNamespaces.mockRejectedValue(errorResponse); + + const result = await sut.fetchObjectsByServiceId( + 'some-service', + { + name: 'cluster1', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + }, + new Set(['pods', 'services']), + ); + + expect(result).toStrictEqual({ + errors: [expectedResult], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: 'pod-name', + }, + }, + ], + }, + ], + }); + + expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(1); + expect(clientMock.listServiceForAllNamespaces.mock.calls.length).toBe(1); + + expect( + kubernetesClientProvider.getAppsClientByClusterDetails.mock.calls.length, + ).toBe(2); + expect( + kubernetesClientProvider.getCoreClientByClusterDetails.mock.calls.length, + ).toBe(2); + }; + + it('should return pods, services', async () => { + clientMock.listPodForAllNamespaces.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { + name: 'pod-name', + }, + }, + ], + }, + }); + + clientMock.listServiceForAllNamespaces.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { + name: 'service-name', + }, + }, + ], + }, + }); + + const result = await sut.fetchObjectsByServiceId( + 'some-service', + { + name: 'cluster1', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + }, + new Set(['pods', 'services']), + ); + + expect(result).toStrictEqual({ + errors: [], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: 'pod-name', + }, + }, + ], + }, + { + type: 'services', + resources: [ + { + metadata: { + name: 'service-name', + }, + }, + ], + }, + ], + }); + + expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(1); + expect(clientMock.listServiceForAllNamespaces.mock.calls.length).toBe(1); + + expect( + kubernetesClientProvider.getAppsClientByClusterDetails.mock.calls.length, + ).toBe(2); + expect( + kubernetesClientProvider.getCoreClientByClusterDetails.mock.calls.length, + ).toBe(2); + }); + it('should throw error on unknown type', () => { + expect(() => + sut.fetchObjectsByServiceId( + 'some-service', + { + name: 'cluster1', + url: 'http://localhost:9999', + serviceAccountToken: 'token', + authProvider: 'serviceAccount', + }, + new Set(['foo']), + ), + ).toThrow('unrecognised type=foo'); + + expect(clientMock.listPodForAllNamespaces.mock.calls.length).toBe(0); + expect(clientMock.listServiceForAllNamespaces.mock.calls.length).toBe(0); + + expect( + kubernetesClientProvider.getAppsClientByClusterDetails.mock.calls.length, + ).toBe(0); + expect( + kubernetesClientProvider.getCoreClientByClusterDetails.mock.calls.length, + ).toBe(0); + }); + // they're in testErrorResponse + // eslint-disable-next-line jest/expect-expect + it('should return pods, unauthorized error', async () => { + await testErrorResponse( + { + response: { + statusCode: 401, + request: { + uri: { + pathname: '/some/path', + }, + }, + }, + }, + { + errorType: 'UNAUTHORIZED_ERROR', + resourcePath: '/some/path', + statusCode: 401, + }, + ); + }); + // they're in testErrorResponse + // eslint-disable-next-line jest/expect-expect + it('should return pods, system error', async () => { + await testErrorResponse( + { + response: { + statusCode: 500, + request: { + uri: { + pathname: '/some/path', + }, + }, + }, + }, + { + errorType: 'SYSTEM_ERROR', + resourcePath: '/some/path', + statusCode: 500, + }, + ); + }); + // they're in testErrorResponse + // eslint-disable-next-line jest/expect-expect + it('should return pods, unknown error', async () => { + await testErrorResponse( + { + response: { + statusCode: 900, + request: { + uri: { + pathname: '/some/path', + }, + }, + }, + }, + { + errorType: 'UNKNOWN_ERROR', + resourcePath: '/some/path', + statusCode: 900, + }, + ); + }); +}); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts new file mode 100644 index 0000000000..f66a45816a --- /dev/null +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -0,0 +1,296 @@ +/* + * 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 http from 'http'; +import { + AppsV1Api, + AutoscalingV1Api, + CoreV1Api, + ExtensionsV1beta1Ingress, + NetworkingV1beta1Api, + V1ConfigMap, + V1Deployment, + V1HorizontalPodAutoscaler, + V1Pod, + V1ReplicaSet, +} from '@kubernetes/client-node'; +import { KubernetesClientProvider } from './KubernetesClientProvider'; +import { V1Service } from '@kubernetes/client-node/dist/gen/model/v1Service'; +import { Logger } from 'winston'; +import { + KubernetesFetcher, + ClusterDetails, + KubernetesObjectTypes, + FetchResponse, + FetchResponseWrapper, + KubernetesFetchError, + KubernetesErrorTypes, +} from '..'; +import lodash, { Dictionary } from 'lodash'; + +export interface Clients { + core: CoreV1Api; + apps: AppsV1Api; + autoscaling: AutoscalingV1Api; + networkingBeta1: NetworkingV1beta1Api; +} + +export interface KubernetesClientBasedFetcherOptions { + kubernetesClientProvider: KubernetesClientProvider; + logger: Logger; +} + +type FetchResult = FetchResponse | KubernetesFetchError; + +const isError = (fr: FetchResult): fr is KubernetesFetchError => + fr.hasOwnProperty('errorType'); + +function fetchResultsToResponseWrapper( + results: FetchResult[], +): FetchResponseWrapper { + const groupBy: Dictionary = lodash.groupBy(results, value => { + return isError(value) ? 'errors' : 'responses'; + }); + + return { + errors: groupBy.errors ?? [], + responses: groupBy.responses ?? [], + } as FetchResponseWrapper; // TODO would be nice to get rid of this 'as' +} + +const statusCodeToErrorType = (statusCode: number): KubernetesErrorTypes => { + switch (statusCode) { + case 401: + return 'UNAUTHORIZED_ERROR'; + case 500: + return 'SYSTEM_ERROR'; + default: + return 'UNKNOWN_ERROR'; + } +}; + +const captureKubernetesErrorsRethrowOthers = (e: any): KubernetesFetchError => { + if (e.response && e.response.statusCode) { + return { + errorType: statusCodeToErrorType(e.response.statusCode), + statusCode: e.response.statusCode, + resourcePath: e.response.request.uri.pathname, + }; + } + throw e; +}; + +export class KubernetesClientBasedFetcher implements KubernetesFetcher { + private readonly kubernetesClientProvider: KubernetesClientProvider; + private readonly logger: Logger; + + constructor({ + kubernetesClientProvider, + logger, + }: KubernetesClientBasedFetcherOptions) { + this.kubernetesClientProvider = kubernetesClientProvider; + this.logger = logger; + } + + fetchObjectsByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + objectTypesToFetch: Set, + ): Promise { + const fetchResults = Array.from(objectTypesToFetch).map(type => { + return this.fetchByObjectType(serviceId, clusterDetails, type).catch( + captureKubernetesErrorsRethrowOthers, + ); + }); + + return Promise.all(fetchResults).then(fetchResultsToResponseWrapper); + } + + // TODO could probably do with a tidy up + private fetchByObjectType( + serviceId: string, + clusterDetails: ClusterDetails, + type: KubernetesObjectTypes, + ): Promise { + switch (type) { + case 'pods': + return this.fetchPodsByServiceId(serviceId, clusterDetails).then(r => ({ + type: type, + resources: r, + })); + case 'configmaps': + return this.fetchConfigMapsByServiceId( + serviceId, + clusterDetails, + ).then(r => ({ type: type, resources: r })); + case 'deployments': + return this.fetchDeploymentsByServiceId( + serviceId, + clusterDetails, + ).then(r => ({ type: type, resources: r })); + case 'replicasets': + return this.fetchReplicaSetsByServiceId( + serviceId, + clusterDetails, + ).then(r => ({ type: type, resources: r })); + case 'services': + return this.fetchServicesByServiceId( + serviceId, + clusterDetails, + ).then(r => ({ type: type, resources: r })); + case 'horizontalpodautoscalers': + return this.fetchHorizontalPodAutoscalersByServiceId( + serviceId, + clusterDetails, + ).then(r => ({ type: type, resources: r })); + case 'ingresses': + return this.fetchIngressesByServiceId( + serviceId, + clusterDetails, + ).then(r => ({ type: type, resources: r })); + default: + // unrecognised type + throw new Error(`unrecognised type=${type}`); + } + } + + private singleClusterFetch( + clusterDetails: ClusterDetails, + fn: ( + client: Clients, + ) => Promise<{ body: { items: Array }; response: http.IncomingMessage }>, + ): Promise> { + const core = this.kubernetesClientProvider.getCoreClientByClusterDetails( + clusterDetails, + ); + const apps = this.kubernetesClientProvider.getAppsClientByClusterDetails( + clusterDetails, + ); + const autoscaling = this.kubernetesClientProvider.getAutoscalingClientByClusterDetails( + clusterDetails, + ); + const networkingBeta1 = this.kubernetesClientProvider.getNetworkingBeta1Client( + clusterDetails, + ); + + this.logger.debug(`calling cluster=${clusterDetails.name}`); + return fn({ core, apps, autoscaling, networkingBeta1 }).then(({ body }) => { + return body.items; + }); + } + + private fetchServicesByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + ): Promise> { + return this.singleClusterFetch(clusterDetails, ({ core }) => + core.listServiceForAllNamespaces( + false, + '', + '', + `backstage.io/kubernetes-id=${serviceId}`, + ), + ); + } + + private fetchPodsByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + ): Promise> { + return this.singleClusterFetch(clusterDetails, ({ core }) => + core.listPodForAllNamespaces( + false, + '', + '', + `backstage.io/kubernetes-id=${serviceId}`, + ), + ); + } + + private fetchConfigMapsByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + ): Promise> { + return this.singleClusterFetch(clusterDetails, ({ core }) => + core.listConfigMapForAllNamespaces( + false, + '', + '', + `backstage.io/kubernetes-id=${serviceId}`, + ), + ); + } + + private fetchDeploymentsByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + ): Promise> { + return this.singleClusterFetch(clusterDetails, ({ apps }) => + apps.listDeploymentForAllNamespaces( + false, + '', + '', + `backstage.io/kubernetes-id=${serviceId}`, + ), + ); + } + + private fetchReplicaSetsByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + ): Promise> { + return this.singleClusterFetch(clusterDetails, ({ apps }) => + apps.listReplicaSetForAllNamespaces( + false, + '', + '', + `backstage.io/kubernetes-id=${serviceId}`, + ), + ); + } + + private fetchHorizontalPodAutoscalersByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + ): Promise> { + return this.singleClusterFetch( + clusterDetails, + ({ autoscaling }) => + autoscaling.listHorizontalPodAutoscalerForAllNamespaces( + false, + '', + '', + `backstage.io/kubernetes-id=${serviceId}`, + ), + ); + } + + private fetchIngressesByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + ): Promise> { + return this.singleClusterFetch( + clusterDetails, + ({ networkingBeta1 }) => + networkingBeta1.listIngressForAllNamespaces( + false, + '', + '', + `backstage.io/kubernetes-id=${serviceId}`, + ), + ); + } +} diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts new file mode 100644 index 0000000000..4218c64e80 --- /dev/null +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.test.ts @@ -0,0 +1,257 @@ +/* + * 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 { handleGetKubernetesObjectsByServiceId } from './getKubernetesObjectsByServiceIdHandler'; +import { getVoidLogger } from '@backstage/backend-common'; +import { ClusterDetails } from '..'; + +const TEST_SERVICE_ID = 'my-service'; + +const fetchObjectsByServiceId = jest.fn(); + +const getClusterByServiceId = jest.fn(); + +const mockFetch = (mock: jest.Mock) => { + mock.mockImplementation((serviceId: string, clusterDetails: ClusterDetails) => + Promise.resolve({ + errors: [], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: `my-pods-${serviceId}-${clusterDetails.name}`, + }, + }, + ], + }, + { + type: 'configmaps', + resources: [ + { + metadata: { + name: `my-configmaps-${serviceId}-${clusterDetails.name}`, + }, + }, + ], + }, + { + type: 'services', + resources: [ + { + metadata: { + name: `my-services-${serviceId}-${clusterDetails.name}`, + }, + }, + ], + }, + ], + }), + ); +}; + +describe('handleGetKubernetesObjectsByServiceId', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('retrieve objects for one cluster', async () => { + getClusterByServiceId.mockImplementation(() => + Promise.resolve([ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + }, + ]), + ); + + mockFetch(fetchObjectsByServiceId); + + const result = await handleGetKubernetesObjectsByServiceId( + TEST_SERVICE_ID, + { + fetchObjectsByServiceId, + }, + { + getClusterByServiceId, + }, + getVoidLogger(), + {}, + ); + + expect(getClusterByServiceId.mock.calls.length).toBe(1); + expect(fetchObjectsByServiceId.mock.calls.length).toBe(1); + expect(result).toStrictEqual({ + items: [ + { + cluster: { + name: 'test-cluster', + }, + errors: [], + resources: [ + { + resources: [ + { + metadata: { + name: 'my-pods-my-service-test-cluster', + }, + }, + ], + type: 'pods', + }, + { + resources: [ + { + metadata: { + name: 'my-configmaps-my-service-test-cluster', + }, + }, + ], + type: 'configmaps', + }, + { + resources: [ + { + metadata: { + name: 'my-services-my-service-test-cluster', + }, + }, + ], + type: 'services', + }, + ], + }, + ], + }); + }); + + it('retrieve objects for two clusters', async () => { + getClusterByServiceId.mockImplementation(() => + Promise.resolve([ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + }, + { + name: 'other-cluster', + authProvider: 'google', + }, + ]), + ); + + mockFetch(fetchObjectsByServiceId); + + const result = await handleGetKubernetesObjectsByServiceId( + TEST_SERVICE_ID, + { + fetchObjectsByServiceId, + }, + { + getClusterByServiceId, + }, + getVoidLogger(), + { + auth: { + google: 'google_token_123', + }, + }, + ); + + expect(getClusterByServiceId.mock.calls.length).toBe(1); + expect(fetchObjectsByServiceId.mock.calls.length).toBe(2); + expect(result).toStrictEqual({ + items: [ + { + cluster: { + name: 'test-cluster', + }, + errors: [], + resources: [ + { + resources: [ + { + metadata: { + name: 'my-pods-my-service-test-cluster', + }, + }, + ], + type: 'pods', + }, + { + resources: [ + { + metadata: { + name: 'my-configmaps-my-service-test-cluster', + }, + }, + ], + type: 'configmaps', + }, + { + resources: [ + { + metadata: { + name: 'my-services-my-service-test-cluster', + }, + }, + ], + type: 'services', + }, + ], + }, + { + cluster: { + name: 'other-cluster', + }, + errors: [], + resources: [ + { + resources: [ + { + metadata: { + name: 'my-pods-my-service-other-cluster', + }, + }, + ], + type: 'pods', + }, + { + resources: [ + { + metadata: { + name: 'my-configmaps-my-service-other-cluster', + }, + }, + ], + type: 'configmaps', + }, + { + resources: [ + { + metadata: { + name: 'my-services-my-service-other-cluster', + }, + }, + ], + type: 'services', + }, + ], + }, + ], + }); + }); +}); diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts new file mode 100644 index 0000000000..f0b21cbc0c --- /dev/null +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsByServiceIdHandler.ts @@ -0,0 +1,96 @@ +/* + * 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 { Logger } from 'winston'; +import { + AuthRequestBody, + ClusterDetails, + KubernetesClusterLocator, + KubernetesFetcher, + KubernetesObjectTypes, + ObjectsByServiceIdResponse, +} from '../types/types'; +import { KubernetesAuthTranslator } from '../kubernetes-auth-translator/types'; +import { KubernetesAuthTranslatorGenerator } from '../kubernetes-auth-translator/KubernetesAuthTranslatorGenerator'; + +export type GetKubernetesObjectsByServiceIdHandler = ( + serviceId: string, + fetcher: KubernetesFetcher, + clusterLocator: KubernetesClusterLocator, + logger: Logger, + requestBody: AuthRequestBody, + objectsToFetch?: Set, +) => Promise; + +const DEFAULT_OBJECTS = new Set([ + 'pods', + 'services', + 'configmaps', + 'deployments', + 'replicasets', + 'horizontalpodautoscalers', + 'ingresses', +]); + +// Fans out the request to all clusters that the service lives in, aggregates their responses together +export const handleGetKubernetesObjectsByServiceId: GetKubernetesObjectsByServiceIdHandler = async ( + serviceId, + fetcher, + clusterLocator, + logger, + requestBody, + objectsToFetch = DEFAULT_OBJECTS, +) => { + const clusterDetails: ClusterDetails[] = await clusterLocator.getClusterByServiceId( + serviceId, + ); + + // Execute all of these async actions simultaneously/without blocking sequentially as no common object is modified by them + const promises: Promise[] = clusterDetails.map(cd => { + const kubernetesAuthTranslator: KubernetesAuthTranslator = KubernetesAuthTranslatorGenerator.getKubernetesAuthTranslatorInstance( + cd.authProvider, + ); + return kubernetesAuthTranslator.decorateClusterDetailsWithAuth( + cd, + requestBody, + ); + }); + const clusterDetailsDecoratedForAuth: ClusterDetails[] = await Promise.all( + promises, + ); + + logger.info( + `serviceId=${serviceId} clusterDetails=[${clusterDetailsDecoratedForAuth + .map(c => c.name) + .join(', ')}]`, + ); + + return Promise.all( + clusterDetailsDecoratedForAuth.map(cd => { + return fetcher + .fetchObjectsByServiceId(serviceId, cd, objectsToFetch) + .then(result => { + return { + cluster: { + name: cd.name, + }, + resources: result.responses, + errors: result.errors, + }; + }); + }), + ).then(r => ({ items: r })); +}; diff --git a/plugins/kubernetes-backend/src/service/router.test.ts b/plugins/kubernetes-backend/src/service/router.test.ts new file mode 100644 index 0000000000..4e9a206eb4 --- /dev/null +++ b/plugins/kubernetes-backend/src/service/router.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 { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; +import { makeRouter } from './router'; +import { + KubernetesClusterLocator, + KubernetesFetcher, + ObjectsByServiceIdResponse, +} from '..'; + +describe('router', () => { + let app: express.Express; + let kubernetesFetcher: jest.Mocked; + let kubernetesClusterLocator: jest.Mocked; + let handleGetByServiceId: jest.Mock>; + + beforeAll(async () => { + kubernetesFetcher = { + fetchObjectsByServiceId: jest.fn(), + }; + + kubernetesClusterLocator = { + getClusterByServiceId: jest.fn(), + }; + + handleGetByServiceId = jest.fn(); + + const router = makeRouter( + getVoidLogger(), + kubernetesFetcher, + kubernetesClusterLocator, + handleGetByServiceId as any, + ); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('post /services/:serviceId', () => { + it('happy path: lists kubernetes objects without auth in request body', async () => { + const result = { + clusterOne: { + pods: [ + { + metadata: { + name: 'pod1', + }, + }, + ], + }, + } as any; + handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result)); + + const response = await request(app).post('/services/test-service'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(result); + }); + + it('happy path: lists kubernetes objects with auth in request body', async () => { + const result = { + clusterOne: { + pods: [ + { + metadata: { + name: 'pod1', + }, + }, + ], + }, + } as any; + handleGetByServiceId.mockReturnValueOnce(Promise.resolve(result)); + + const response = await request(app) + .post('/services/test-service') + .send({ + auth: { + google: 'google_token_123', + }, + }) + .set('Content-Type', 'application/json'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(result); + }); + + it('internal error: lists kubernetes objects', async () => { + handleGetByServiceId.mockRejectedValue(Error('some internal error')); + + const response = await request(app).post('/services/test-service'); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ error: 'some internal error' }); + }); + }); +}); diff --git a/plugins/kubernetes-backend/src/service/router.ts b/plugins/kubernetes-backend/src/service/router.ts new file mode 100644 index 0000000000..9e9b4ab2f5 --- /dev/null +++ b/plugins/kubernetes-backend/src/service/router.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. + */ + +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 { KubernetesClientBasedFetcher } from './KubernetesFetcher'; +import { KubernetesClientProvider } from './KubernetesClientProvider'; +import { + GetKubernetesObjectsByServiceIdHandler, + handleGetKubernetesObjectsByServiceId, +} from './getKubernetesObjectsByServiceIdHandler'; +import { + AuthRequestBody, + KubernetesClusterLocator, + KubernetesFetcher, +} from '../types/types'; + +export interface RouterOptions { + logger: Logger; + config: Config; +} + +const getClusterLocator = (config: Config): KubernetesClusterLocator => { + const clusterLocatorMethod = config.getString( + 'kubernetes.clusterLocatorMethod', + ) as ClusterLocatorMethod; + + switch (clusterLocatorMethod) { + case 'configMultiTenant': + return MultiTenantConfigClusterLocator.fromConfig( + config.getConfigArray('kubernetes.clusters'), + ); + case 'http': + throw new Error('not implemented'); + default: + throw new Error( + `Unsupported kubernetes.clusterLocatorMethod "${clusterLocatorMethod}"`, + ); + } +}; + +export const makeRouter = ( + logger: Logger, + fetcher: KubernetesFetcher, + clusterLocator: KubernetesClusterLocator, + 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; + try { + const response = await handleGetByServiceId( + serviceId, + fetcher, + clusterLocator, + logger, + requestBody, + ); + res.send(response); + } catch (e) { + logger.error( + `action=retrieveObjectsByServiceId service=${serviceId}, error=${e}`, + ); + res.status(500).send({ error: e.message }); + } + }); + + return router; +}; + +export async function createRouter( + options: RouterOptions, +): Promise { + const logger = options.logger; + + logger.info('Initializing Kubernetes backend'); + + const clusterLocator = getClusterLocator(options.config); + + const fetcher = new KubernetesClientBasedFetcher({ + kubernetesClientProvider: new KubernetesClientProvider(), + logger, + }); + + return makeRouter( + logger, + fetcher, + clusterLocator, + handleGetKubernetesObjectsByServiceId, + ); +} diff --git a/plugins/identity-backend/src/service/standaloneApplication.ts b/plugins/kubernetes-backend/src/service/standaloneApplication.ts similarity index 90% rename from plugins/identity-backend/src/service/standaloneApplication.ts rename to plugins/kubernetes-backend/src/service/standaloneApplication.ts index 5eea4fb8f5..e23e87a7a2 100644 --- a/plugins/identity-backend/src/service/standaloneApplication.ts +++ b/plugins/kubernetes-backend/src/service/standaloneApplication.ts @@ -25,6 +25,7 @@ import express from 'express'; import helmet from 'helmet'; import { Logger } from 'winston'; import { createRouter } from './router'; +import { ConfigReader } from '@backstage/config'; export interface ApplicationOptions { enableCors: boolean; @@ -35,6 +36,7 @@ export async function createStandaloneApplication( options: ApplicationOptions, ): Promise { const { enableCors, logger } = options; + const config = ConfigReader.fromConfigs([]); const app = express(); app.use(helmet()); @@ -44,7 +46,7 @@ export async function createStandaloneApplication( app.use(compression()); app.use(express.json()); app.use(requestLoggingHandler()); - app.use('/', await createRouter({ logger })); + app.use('/', await createRouter({ logger, config })); app.use(notFoundHandler()); app.use(errorHandler()); diff --git a/plugins/identity-backend/src/service/standaloneServer.ts b/plugins/kubernetes-backend/src/service/standaloneServer.ts similarity index 94% rename from plugins/identity-backend/src/service/standaloneServer.ts rename to plugins/kubernetes-backend/src/service/standaloneServer.ts index 42231b45a9..9831bc986b 100644 --- a/plugins/identity-backend/src/service/standaloneServer.ts +++ b/plugins/kubernetes-backend/src/service/standaloneServer.ts @@ -27,7 +27,7 @@ export interface ServerOptions { export async function startStandaloneServer( options: ServerOptions, ): Promise { - const logger = options.logger.child({ service: 'identity-backend' }); + const logger = options.logger.child({ service: 'kubernetes-backend' }); logger.debug('Creating application...'); const app = await createStandaloneApplication({ diff --git a/plugins/identity-backend/src/setupTests.ts b/plugins/kubernetes-backend/src/setupTests.ts similarity index 100% rename from plugins/identity-backend/src/setupTests.ts rename to plugins/kubernetes-backend/src/setupTests.ts diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts new file mode 100644 index 0000000000..eb2817f268 --- /dev/null +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -0,0 +1,134 @@ +/* + * 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 { + ExtensionsV1beta1Ingress, + V1ConfigMap, + V1Deployment, + V1HorizontalPodAutoscaler, + V1Pod, + V1ReplicaSet, + V1Service, +} from '@kubernetes/client-node'; + +export interface ClusterDetails { + name: string; + url: string; + authProvider: string; + serviceAccountToken?: string | undefined; +} + +export interface AuthRequestBody { + auth?: { + google?: string; + }; +} + +export interface ClusterObjects { + cluster: { name: string }; + resources: FetchResponse[]; + errors: KubernetesFetchError[]; +} + +export interface ObjectsByServiceIdResponse { + items: ClusterObjects[]; +} + +export interface FetchResponseWrapper { + errors: KubernetesFetchError[]; + responses: FetchResponse[]; +} + +export type FetchResponse = + | PodFetchResponse + | ServiceFetchResponse + | ConfigMapFetchResponse + | DeploymentFetchResponse + | ReplicaSetsFetchResponse + | HorizontalPodAutoscalersFetchResponse + | IngressesFetchResponse; + +// TODO fairly sure there's a easier way to do this + +export type KubernetesObjectTypes = + | 'pods' + | 'services' + | 'configmaps' + | 'deployments' + | 'replicasets' + | 'horizontalpodautoscalers' + | 'ingresses'; + +export interface PodFetchResponse { + type: 'pods'; + resources: Array; +} + +export interface ServiceFetchResponse { + type: 'services'; + resources: Array; +} + +export interface ConfigMapFetchResponse { + type: 'configmaps'; + resources: Array; +} + +export interface DeploymentFetchResponse { + type: 'deployments'; + resources: Array; +} + +export interface ReplicaSetsFetchResponse { + type: 'replicasets'; + resources: Array; +} + +export interface HorizontalPodAutoscalersFetchResponse { + type: 'horizontalpodautoscalers'; + resources: Array; +} + +export interface IngressesFetchResponse { + type: 'ingresses'; + resources: Array; +} + +// Fetches information from a kubernetes cluster using the cluster details object +// to target a specific cluster +export interface KubernetesFetcher { + fetchObjectsByServiceId( + serviceId: string, + clusterDetails: ClusterDetails, + objectTypesToFetch: Set, + ): Promise; +} + +// Used to locate which cluster(s) a service is running on +export interface KubernetesClusterLocator { + getClusterByServiceId(serviceId: string): Promise; +} + +export type KubernetesErrorTypes = + | 'UNAUTHORIZED_ERROR' + | 'SYSTEM_ERROR' + | 'UNKNOWN_ERROR'; + +export interface KubernetesFetchError { + errorType: KubernetesErrorTypes; + statusCode?: number; + resourcePath?: string; +} diff --git a/plugins/kubernetes/.eslintrc.js b/plugins/kubernetes/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/kubernetes/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/kubernetes/README.md b/plugins/kubernetes/README.md new file mode 100644 index 0000000000..678c9a96a6 --- /dev/null +++ b/plugins/kubernetes/README.md @@ -0,0 +1,13 @@ +# kubernetes + +Welcome to the kubernetes plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/kubernetes](http://localhost:3000/kubernetes). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx new file mode 100644 index 0000000000..f352fb9c34 --- /dev/null +++ b/plugins/kubernetes/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'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json new file mode 100644 index 0000000000..2adefebbd6 --- /dev/null +++ b/plugins/kubernetes/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-kubernetes", + "version": "0.1.1-alpha.24", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "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.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", + "@kubernetes/client-node": "^0.12.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-dom": "6.0.0-beta.0", + "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", + "@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" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/kubernetes/src/Router.tsx b/plugins/kubernetes/src/Router.tsx new file mode 100644 index 0000000000..41f534fbef --- /dev/null +++ b/plugins/kubernetes/src/Router.tsx @@ -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 React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Route, Routes } from 'react-router-dom'; + +import { rootCatalogKubernetesRouteRef } from './plugin'; +import { KubernetesContent } from './components/KubernetesContent'; +import { WarningPanel } from '@backstage/core'; + +const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; + +export const Router = ({ entity }: { entity: Entity }) => { + const kubernetesAnnotationValue = + entity.metadata.annotations?.[KUBERNETES_ANNOTATION]; + + if (!kubernetesAnnotationValue) { + return ( + +

{KUBERNETES_ANNOTATION}
annotation is missing on the entity. + + ); + } + + return ( + + } + /> + + ); +}; diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts new file mode 100644 index 0000000000..86bb830046 --- /dev/null +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.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 { DiscoveryApi } from '@backstage/core'; +import { KubernetesApi } from './types'; +import { + AuthRequestBody, + ObjectsByServiceIdResponse, +} from '@backstage/plugin-kubernetes-backend'; + +export class KubernetesBackendClient implements KubernetesApi { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = options.discoveryApi; + } + + private async getRequired( + path: string, + requestBody: AuthRequestBody, + ): Promise { + const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const payload = await response.text(); + const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + throw new Error(message); + } + + return await response.json(); + } + + async getObjectsByServiceId( + serviceId: String, + requestBody: AuthRequestBody, + ): Promise { + return await this.getRequired(`/services/${serviceId}`, requestBody); + } +} diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts new file mode 100644 index 0000000000..8e44d58e6a --- /dev/null +++ b/plugins/kubernetes/src/api/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 { createApiRef } from '@backstage/core'; +import { + AuthRequestBody, + ObjectsByServiceIdResponse, +} from '@backstage/plugin-kubernetes-backend'; + +export const kubernetesApiRef = createApiRef({ + id: 'plugin.kubernetes.service', + description: + 'Used by the Kubernetes plugin to make requests to accompanying backend', +}); + +export interface KubernetesApi { + getObjectsByServiceId( + serviceId: String, + requestBody: AuthRequestBody, + ): Promise; +} diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx new file mode 100644 index 0000000000..35b8f2143a --- /dev/null +++ b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.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 React from 'react'; +import { render } from '@testing-library/react'; +import { ConfigMaps } from './ConfigMaps'; +import * as configmapFixture from './__fixtures__/configmap.json'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('ConfigMaps', () => { + it('should render configmap', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('Config Map')).toBeInTheDocument(); + + // values + expect(getByText('Immutable')).toBeInTheDocument(); + expect(getByText('false')).toBeInTheDocument(); + expect(getByText('Data')).toBeInTheDocument(); + expect(getByText('Foo: bar')).toBeInTheDocument(); // TODO wish this wasn't upper case + }); +}); diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx new file mode 100644 index 0000000000..5a013eb2c6 --- /dev/null +++ b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid } from '@material-ui/core'; +import { V1ConfigMap } from '@kubernetes/client-node'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core'; + +type ConfigMapsProps = { + configMaps: V1ConfigMap[]; + children?: React.ReactNode; +}; + +export const ConfigMaps = ({ configMaps }: ConfigMapsProps) => { + return ( + + {configMaps.map((cm, i) => { + return ( + + +
+ +
+
+
+ ); + })} +
+ ); +}; diff --git a/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json b/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json new file mode 100644 index 0000000000..77ea844611 --- /dev/null +++ b/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json @@ -0,0 +1,46 @@ +[ + { + "data": { + "foo": "bar" + }, + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"foo\":\"bar\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"}}\n" + }, + "creationTimestamp": "2020-09-24T11:39:26.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:data": { + ".": {}, + "f:foo": {} + }, + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-24T11:39:26.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "503867", + "selfLink": "/api/v1/namespaces/default/configmaps/dice-roller", + "uid": "e9efe5ee-53b9-4422-aef2-877a03c73d5f" + } + } +] diff --git a/plugins/kubernetes/src/components/ConfigMaps/index.ts b/plugins/kubernetes/src/components/ConfigMaps/index.ts new file mode 100644 index 0000000000..b1c7d37a5a --- /dev/null +++ b/plugins/kubernetes/src/components/ConfigMaps/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 { ConfigMaps } from './ConfigMaps'; diff --git a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx new file mode 100644 index 0000000000..3dacac7055 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.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 React from 'react'; +import { render } from '@testing-library/react'; +import { DeploymentTables } from './DeploymentTables'; +import * as twoDeployFixture from './__fixtures__/2-deployments.json'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('DeploymentTables', () => { + it('should render 2 deployments', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('dice-roller-canary')).toBeInTheDocument(); + + // pod names + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect( + getByText('dice-roller-canary-7d64cd756c-55rfq'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx new file mode 100644 index 0000000000..a009c20d7e --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx @@ -0,0 +1,220 @@ +/* + * 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, { Fragment } from 'react'; +import { Chip, Grid } from '@material-ui/core'; +import { + StatusAborted, + StatusError, + StatusOK, + SubvalueCell, + Table, + TableColumn, +} from '@backstage/core'; +import { + V1ComponentCondition, + V1Deployment, + V1Pod, + V1ReplicaSet, +} from '@kubernetes/client-node'; +import { V1OwnerReference } from '@kubernetes/client-node/dist/gen/model/v1OwnerReference'; +import { DeploymentTriple } from '../../types/types'; + +const renderCondition = (condition: V1ComponentCondition | undefined) => { + if (!condition) { + return ; + } + + const status = condition.status; + + if (status === 'True') { + return ; + } else if (status === 'False') { + return ; + } + return ; +}; + +const columns: TableColumn[] = [ + { + title: 'name', + highlight: true, + width: '20%', + render: (pod: V1Pod) => pod.metadata?.name ?? 'un-named pod', + }, + { + title: 'images', + width: '20%', + render: (pod: V1Pod) => { + const containerStatuses = pod.status?.containerStatuses ?? []; + return containerStatuses.map((cs, i) => { + return ; + }); + }, + }, + { + title: 'phase', + render: (pod: V1Pod) => pod.status?.phase ?? 'unknown', + }, + { + title: 'containers ready', + align: 'center', + render: (pod: V1Pod) => { + const containerStatuses = pod.status?.containerStatuses ?? []; + const containersReady = containerStatuses.filter(cs => cs.ready).length; + + return `${containersReady}/${containerStatuses.length}`; + }, + }, + { + title: 'total restarts', + render: (pod: V1Pod) => { + const containerStatuses = pod.status?.containerStatuses ?? []; + return containerStatuses?.reduce((a, b) => a + b.restartCount, 0); + }, + type: 'numeric', + }, + { + title: 'status', + width: '20%', + render: (pod: V1Pod) => { + const containerStatuses = pod.status?.containerStatuses ?? []; + const errors = containerStatuses.reduce((accum, next) => { + if (next.state === undefined) { + return accum; + } + + const waiting = next.state.waiting; + const terminated = next.state.terminated; + + const renderCell = (reason: string | undefined) => ( + + Container: {next.name}} + subvalue={reason} + /> +
+ + ); + + if (waiting) { + accum.push(renderCell(waiting.reason)); + } + + if (terminated) { + accum.push(renderCell(terminated.reason)); + } + + return accum; + }, [] as React.ReactNode[]); + + if (errors.length === 0) { + return OK; + } + + return errors; + }, + }, + { + title: 'Pod Initialized', + align: 'center', + render: (pod: V1Pod) => { + const conditions = pod.status?.conditions ?? []; + return renderCondition(conditions.find(c => c.type === 'Initialized')); + }, + }, + { + title: 'Pod Ready', + align: 'center', + render: (pod: V1Pod) => { + const conditions = pod.status?.conditions ?? []; + return renderCondition(conditions.find(c => c.type === 'Ready')); + }, + }, + { + title: 'Containers Ready', + align: 'center', + render: (pod: V1Pod) => { + const conditions = pod.status?.conditions ?? []; + return renderCondition( + conditions.find(c => c.type === 'ContainersReady'), + ); + }, + }, + { + title: 'Pod Scheduled', + align: 'center', + render: (pod: V1Pod) => { + const conditions = pod.status?.conditions ?? []; + return renderCondition(conditions.find(c => c.type === 'PodScheduled')); + }, + }, +]; + +type DeploymentTablesProps = { + deploymentTriple: DeploymentTriple; + children?: React.ReactNode; +}; + +export const DeploymentTables = ({ + deploymentTriple, +}: DeploymentTablesProps) => { + const isOwnedBy = ( + ownerReferences: V1OwnerReference[], + obj: V1Pod | V1ReplicaSet | V1Deployment, + ): boolean => { + return ownerReferences?.some(or => or.name === obj.metadata?.name); + }; + + return ( + + {deploymentTriple.deployments.map((deployment, i) => ( + + {deploymentTriple.replicaSets + // Filter out replica sets with no replicas + .filter(rs => rs.status && rs.status.replicas > 0) + // Find the replica sets this deployment owns + .filter(rs => + isOwnedBy(rs.metadata?.ownerReferences ?? [], deployment), + ) + .map((rs, j) => { + // Find the pods this replica set owns and render them in the table + const ownedPods = deploymentTriple.pods.filter(pod => + isOwnedBy(pod.metadata?.ownerReferences ?? [], rs), + ); + + return ( + +
+ + ); + })} + + ))} + + ); +}; diff --git a/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json b/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json new file mode 100644 index 0000000000..e51d0d0b5f --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json @@ -0,0 +1,4435 @@ +{ + "pods": [ + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.11\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-2m5hv", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593216", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-2m5hv", + "uid": "aadb71c0-36fa-43e3-b38a-162f134d4359" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://aa4489297c34c48bb33c18474a8d2b33854a82ed42155680b259f635f556ce70", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.11", + "podIPs": [ + { + "ip": "172.17.0.11" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.7\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-4v6s8", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593221", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-4v6s8", + "uid": "32e56490-6f20-4757-991f-a0c7fb407358" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://d91cc76c41249b8d3dfcf538d180b471b2a7966aae0d17a818307c8a9b4af897", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.7", + "podIPs": [ + { + "ip": "172.17.0.7" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-24T11:39:26.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-24T11:39:26.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.6\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-24T11:39:27.000Z" + } + ], + "name": "dice-roller-6c8646bfd-b9zt5", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "503886", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-b9zt5", + "uid": "8b6601b6-469e-4e89-8fd0-abb2f1a567e4" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:26.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:27.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:27.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:26.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://c50e0d0fa96f09a2ed5df7dd5a7f7de88e6b7c7addb8f002f299d6afc52d82ce", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-24T11:39:27.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.6", + "podIPs": [ + { + "ip": "172.17.0.6" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-24T11:39:26.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.13\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-cfxqc", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593211", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-cfxqc", + "uid": "e87e1776-0ca7-41fb-aeea-e1f648387545" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:51.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://c1641d986aae424429b7c2c1117baeb17d3794f73206bd57292cdf8264f929b2", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.13", + "podIPs": [ + { + "ip": "172.17.0.13" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:51.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.15\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:54.000Z" + } + ], + "name": "dice-roller-6c8646bfd-dtv5z", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593190", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-dtv5z", + "uid": "1638a41b-e47e-4c17-a1f7-7b447df248b6" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:51.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://e62c32db58dbfe0a74b2d2cba0e0a97b7f222693c68e930037ac8738c4758ca3", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.15", + "podIPs": [ + { + "ip": "172.17.0.15" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:51.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.12\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:53.000Z" + } + ], + "name": "dice-roller-6c8646bfd-hhts2", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593186", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-hhts2", + "uid": "ea0b147a-c56a-46e6-9445-7a0b1b0ed3c0" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://bcdbc153630f90556d57ff0d62f04d847ca63a5f0a7e5d9230683623d33d4b8d", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.12", + "podIPs": [ + { + "ip": "172.17.0.12" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.14\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-j68lm", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593226", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-j68lm", + "uid": "b230ff1d-7a13-479b-9c7b-77c26bd79f62" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://30fa8a3429f86ab8e9a4cec472b79d74266609082703f48950f4aae021e9d6a2", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.14", + "podIPs": [ + { + "ip": "172.17.0.14" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.9\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:53.000Z" + } + ], + "name": "dice-roller-6c8646bfd-m6f9w", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593181", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-m6f9w", + "uid": "9b0079f6-ff29-4619-bf6e-71cc10199ac5" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:53.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://f78767ee90eedffe46ff64bfe76d7f69426800dd89f3df012daf0baf3a9c5bdd", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.9", + "podIPs": [ + { + "ip": "172.17.0.9" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-24T11:39:27.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-24T11:39:27.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.4\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-24T11:39:28.000Z" + } + ], + "name": "dice-roller-6c8646bfd-nv9pk", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "503918", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-nv9pk", + "uid": "acf7f77f-78c6-4d4b-bc73-637211770ffc" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:27.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:28.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:28.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-24T11:39:27.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://63dec9b32942c4b09ae43d09d6978261f674a3ee623823b8f1d20da8044c12ac", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-24T11:39:28.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.4", + "podIPs": [ + { + "ip": "172.17.0.4" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-24T11:39:27.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T09:58:50.000Z", + "generateName": "dice-roller-6c8646bfd-", + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.10\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd-pjhfj", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + } + ], + "resourceVersion": "593205", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-pjhfj", + "uid": "78775c3a-7af2-4ebe-a676-bc2e5dfa2bcc" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "status": "True", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T09:58:50.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://cbb83dda250db74285dcbe0b81bd0896acf402bac9710af090311f7360f824aa", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T09:58:53.000Z" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.10", + "podIPs": [ + { + "ip": "172.17.0.10" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T09:58:50.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T10:34:01.000Z", + "generateName": "dice-roller-canary-7d64cd756c-", + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"9208395b-a9a7-4e46-b881-6a189f7fbdb0\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"other-side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":82,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T10:34:01.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.16\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T14:18:54.000Z" + } + ], + "name": "dice-roller-canary-7d64cd756c-55rfq", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-canary-7d64cd756c", + "uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0" + } + ], + "resourceVersion": "620452", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-canary-7d64cd756c-55rfq", + "uid": "65ad28e3-5d51-4b4b-9bf8-4cb069803034" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:01.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:18:53.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:18:53.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:01.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://6ce15178d114a85f3d2e832de45c3355ab5b71ed5f4d4d225ee1c83bf07f69d9", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T10:34:01.000Z" + } + } + }, + { + "containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://b3ce93d7f90bfe22558c61d2505b8473580574accdebb5fa4e51c0729c3511f4", + "exitCode": 1, + "finishedAt": "2020-09-25T14:18:52.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:18:50.000Z" + } + }, + "name": "other-side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)", + "reason": "CrashLoopBackOff" + } + } + }, + { + "containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://b7f0e65a2b8ab48c5f234616cfe8286aa96b55c3ef09c5cfbc4cdbe67a96f8cb", + "exitCode": 1, + "finishedAt": "2020-09-25T14:18:52.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:18:50.000Z" + } + }, + "name": "side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)", + "reason": "CrashLoopBackOff" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.16", + "podIPs": [ + { + "ip": "172.17.0.16" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T10:34:01.000Z" + } + }, + { + "metadata": { + "creationTimestamp": "2020-09-25T10:34:02.000Z", + "generateName": "dice-roller-canary-7d64cd756c-", + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"9208395b-a9a7-4e46-b881-6a189f7fbdb0\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"other-side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":82,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T10:34:02.000Z" + }, + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"172.17.0.5\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "manager": "kubelet", + "operation": "Update", + "time": "2020-09-25T14:19:05.000Z" + } + ], + "name": "dice-roller-canary-7d64cd756c-vtbdx", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-canary-7d64cd756c", + "uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0" + } + ], + "resourceVersion": "620481", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-canary-7d64cd756c-vtbdx", + "uid": "0b8d9d79-b43d-4339-be57-ad5c63add77e" + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-5gctn", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "minikube", + "priority": 0, + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "serviceAccount": "default", + "serviceAccountName": "default", + "terminationGracePeriodSeconds": 30, + "tolerations": [ + { + "effect": "NoExecute", + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "tolerationSeconds": 300 + }, + { + "effect": "NoExecute", + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "tolerationSeconds": 300 + } + ], + "volumes": [ + { + "name": "default-token-5gctn", + "secret": { + "defaultMode": 420, + "secretName": "default-token-5gctn" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:02.000Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:19:04.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T14:19:04.000Z", + "message": "containers with unready status: [side-car other-side-car]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2020-09-25T10:34:02.000Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "containerID": "docker://3f9cadc6f135247eb2df9aaca8f25ea05dcf42be86d58fb833c8acf192da0d66", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": {}, + "name": "nginx", + "ready": true, + "restartCount": 0, + "started": true, + "state": { + "running": { + "startedAt": "2020-09-25T10:34:03.000Z" + } + } + }, + { + "containerID": "docker://5e3a9f9129e5ce74fea249c013afcc056ee95a940fed24495ef9b58df67771f3", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://5e3a9f9129e5ce74fea249c013afcc056ee95a940fed24495ef9b58df67771f3", + "exitCode": 1, + "finishedAt": "2020-09-25T14:19:03.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:19:01.000Z" + } + }, + "name": "other-side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-vtbdx_default(0b8d9d79-b43d-4339-be57-ad5c63add77e)", + "reason": "CrashLoopBackOff" + } + } + }, + { + "containerID": "docker://21b42cae298c0ed7f90373974d8c88b0941d17733f35398144a17ece32e03210", + "image": "nginx:1.14.2", + "imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", + "lastState": { + "terminated": { + "containerID": "docker://21b42cae298c0ed7f90373974d8c88b0941d17733f35398144a17ece32e03210", + "exitCode": 1, + "finishedAt": "2020-09-25T14:19:03.000Z", + "reason": "Error", + "startedAt": "2020-09-25T14:19:01.000Z" + } + }, + "name": "side-car", + "ready": false, + "restartCount": 38, + "started": false, + "state": { + "waiting": { + "message": "back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-vtbdx_default(0b8d9d79-b43d-4339-be57-ad5c63add77e)", + "reason": "CrashLoopBackOff" + } + } + } + ], + "hostIP": "192.168.64.2", + "phase": "Running", + "podIP": "172.17.0.5", + "podIPs": [ + { + "ip": "172.17.0.5" + } + ], + "qosClass": "BestEffort", + "startTime": "2020-09-25T10:34:02.000Z" + } + } + ], + "replicaSets": [ + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/desired-replicas": "10", + "deployment.kubernetes.io/max-replicas": "13", + "deployment.kubernetes.io/revision": "2" + }, + "creationTimestamp": "2020-09-24T11:39:26.000Z", + "generation": 3, + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:deployment.kubernetes.io/desired-replicas": {}, + "f:deployment.kubernetes.io/max-replicas": {}, + "f:deployment.kubernetes.io/revision": {} + }, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"7551e949-42d1-4061-83c5-9da107186e47\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:replicas": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {}, + "f:pod-template-hash": {} + } + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + }, + "f:status": { + "f:availableReplicas": {}, + "f:fullyLabeledReplicas": {}, + "f:observedGeneration": {}, + "f:readyReplicas": {}, + "f:replicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller-6c8646bfd", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Deployment", + "name": "dice-roller", + "uid": "7551e949-42d1-4061-83c5-9da107186e47" + } + ], + "resourceVersion": "593228", + "selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-6c8646bfd", + "uid": "5126c354-4310-4e23-a9e4-c9b87cb69792" + }, + "spec": { + "replicas": 10, + "selector": { + "matchLabels": { + "app": "dice-roller", + "pod-template-hash": "6c8646bfd" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "6c8646bfd" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "availableReplicas": 10, + "fullyLabeledReplicas": 10, + "observedGeneration": 3, + "readyReplicas": 10, + "replicas": 10 + } + }, + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/desired-replicas": "2", + "deployment.kubernetes.io/max-replicas": "3", + "deployment.kubernetes.io/revision": "3" + }, + "creationTimestamp": "2020-09-25T10:34:01.000Z", + "generation": 2, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:deployment.kubernetes.io/desired-replicas": {}, + "f:deployment.kubernetes.io/max-replicas": {}, + "f:deployment.kubernetes.io/revision": {} + }, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"0b6ae80f-999b-40e9-b116-ea925f0ed07b\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:replicas": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {}, + "f:pod-template-hash": {} + } + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"other-side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":82,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + }, + "f:status": { + "f:fullyLabeledReplicas": {}, + "f:observedGeneration": {}, + "f:replicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T14:19:01.000Z" + } + ], + "name": "dice-roller-canary-7d64cd756c", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Deployment", + "name": "dice-roller-canary", + "uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b" + } + ], + "resourceVersion": "620479", + "selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-canary-7d64cd756c", + "uid": "9208395b-a9a7-4e46-b881-6a189f7fbdb0" + }, + "spec": { + "replicas": 2, + "selector": { + "matchLabels": { + "app": "dice-roller-canary", + "pod-template-hash": "7d64cd756c" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "7d64cd756c" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "fullyLabeledReplicas": 2, + "observedGeneration": 2, + "replicas": 2 + } + }, + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/desired-replicas": "2", + "deployment.kubernetes.io/max-replicas": "3", + "deployment.kubernetes.io/revision": "2" + }, + "creationTimestamp": "2020-09-25T09:25:16.000Z", + "generation": 4, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "bcb8d54dd" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:deployment.kubernetes.io/desired-replicas": {}, + "f:deployment.kubernetes.io/max-replicas": {}, + "f:deployment.kubernetes.io/revision": {} + }, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"0b6ae80f-999b-40e9-b116-ea925f0ed07b\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:replicas": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {}, + "f:pod-template-hash": {} + } + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + }, + "f:status": { + "f:observedGeneration": {}, + "f:replicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T10:34:04.000Z" + } + ], + "name": "dice-roller-canary-bcb8d54dd", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Deployment", + "name": "dice-roller-canary", + "uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b" + } + ], + "resourceVersion": "598025", + "selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-canary-bcb8d54dd", + "uid": "51942585-d599-42aa-bf23-9cf1acad4006" + }, + "spec": { + "replicas": 0, + "selector": { + "matchLabels": { + "app": "dice-roller-canary", + "pod-template-hash": "bcb8d54dd" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "bcb8d54dd" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "observedGeneration": 4, + "replicas": 0 + } + }, + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/desired-replicas": "2", + "deployment.kubernetes.io/max-replicas": "3", + "deployment.kubernetes.io/revision": "1" + }, + "creationTimestamp": "2020-09-25T09:02:53.000Z", + "generation": 3, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "c866fbf67" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:deployment.kubernetes.io/desired-replicas": {}, + "f:deployment.kubernetes.io/max-replicas": {}, + "f:deployment.kubernetes.io/revision": {} + }, + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"0b6ae80f-999b-40e9-b116-ea925f0ed07b\"}": { + ".": {}, + "f:apiVersion": {}, + "f:blockOwnerDeletion": {}, + "f:controller": {}, + "f:kind": {}, + "f:name": {}, + "f:uid": {} + } + } + }, + "f:spec": { + "f:replicas": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {}, + "f:pod-template-hash": {} + } + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {}, + "f:pod-template-hash": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + }, + "f:status": { + "f:observedGeneration": {}, + "f:replicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:25:20.000Z" + } + ], + "name": "dice-roller-canary-c866fbf67", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Deployment", + "name": "dice-roller-canary", + "uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b" + } + ], + "resourceVersion": "588501", + "selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-canary-c866fbf67", + "uid": "4d2dfe13-978f-4504-9036-ca585acdea6c" + }, + "spec": { + "replicas": 0, + "selector": { + "matchLabels": { + "app": "dice-roller-canary", + "pod-template-hash": "c866fbf67" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "c866fbf67" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "observedGeneration": 3, + "replicas": 0 + } + } + ], + "deployments": [ + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "2", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"replicas\":10,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.14.2\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n" + }, + "creationTimestamp": "2020-09-23T12:00:55.000Z", + "generation": 3, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:progressDeadlineSeconds": {}, + "f:replicas": {}, + "f:revisionHistoryLimit": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {} + } + }, + "f:strategy": { + "f:rollingUpdate": { + ".": {}, + "f:maxSurge": {}, + "f:maxUnavailable": {} + }, + "f:type": {} + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-25T09:58:50.000Z" + }, + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + "f:deployment.kubernetes.io/revision": {} + } + }, + "f:status": { + "f:availableReplicas": {}, + "f:conditions": { + ".": {}, + "k:{\"type\":\"Available\"}": { + ".": {}, + "f:lastTransitionTime": {}, + "f:lastUpdateTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Progressing\"}": { + ".": {}, + "f:lastTransitionTime": {}, + "f:lastUpdateTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:observedGeneration": {}, + "f:readyReplicas": {}, + "f:replicas": {}, + "f:updatedReplicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T09:58:55.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "593230", + "selfLink": "/apis/apps/v1/namespaces/default/deployments/dice-roller", + "uid": "7551e949-42d1-4061-83c5-9da107186e47" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 10, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "dice-roller" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller", + "backstage.io/kubernetes-id": "dice-roller" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "availableReplicas": 10, + "conditions": [ + { + "lastTransitionTime": "2020-09-23T12:00:55.000Z", + "lastUpdateTime": "2020-09-24T11:39:28.000Z", + "message": "ReplicaSet \"dice-roller-6c8646bfd\" has successfully progressed.", + "reason": "NewReplicaSetAvailable", + "status": "True", + "type": "Progressing" + }, + { + "lastTransitionTime": "2020-09-25T09:58:55.000Z", + "lastUpdateTime": "2020-09-25T09:58:55.000Z", + "message": "Deployment has minimum availability.", + "reason": "MinimumReplicasAvailable", + "status": "True", + "type": "Available" + } + ], + "observedGeneration": 3, + "readyReplicas": 10, + "replicas": 10, + "updatedReplicas": 10 + } + }, + { + "metadata": { + "annotations": { + "deployment.kubernetes.io/revision": "3", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller-canary\",\"namespace\":\"default\"},\"spec\":{\"replicas\":2,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller-canary\"}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller-canary\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.14.2\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]},{\"image\":\"nginx:1.14.2\",\"name\":\"side-car\",\"ports\":[{\"containerPort\":81}]},{\"image\":\"nginx:1.14.2\",\"name\":\"other-side-car\",\"ports\":[{\"containerPort\":82}]}]}}}}\n" + }, + "creationTimestamp": "2020-09-25T09:02:53.000Z", + "generation": 3, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:progressDeadlineSeconds": {}, + "f:replicas": {}, + "f:revisionHistoryLimit": {}, + "f:selector": { + "f:matchLabels": { + ".": {}, + "f:app": {} + } + }, + "f:strategy": { + "f:rollingUpdate": { + ".": {}, + "f:maxSurge": {}, + "f:maxUnavailable": {} + }, + "f:type": {} + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"other-side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":82,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + }, + "k:{\"name\":\"side-car\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:ports": { + ".": {}, + "k:{\"containerPort\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:containerPort": {}, + "f:protocol": {} + } + }, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-25T10:34:01.000Z" + }, + { + "apiVersion": "apps/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + "f:deployment.kubernetes.io/revision": {} + } + }, + "f:status": { + "f:conditions": { + ".": {}, + "k:{\"type\":\"Available\"}": { + ".": {}, + "f:lastTransitionTime": {}, + "f:lastUpdateTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Progressing\"}": { + ".": {}, + "f:lastTransitionTime": {}, + "f:lastUpdateTime": {}, + "f:message": {}, + "f:reason": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:observedGeneration": {}, + "f:replicas": {}, + "f:unavailableReplicas": {}, + "f:updatedReplicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-25T14:19:04.000Z" + } + ], + "name": "dice-roller-canary", + "namespace": "default", + "resourceVersion": "620480", + "selfLink": "/apis/apps/v1/namespaces/default/deployments/dice-roller-canary", + "uid": "0b6ae80f-999b-40e9-b116-ea925f0ed07b" + }, + "spec": { + "progressDeadlineSeconds": 600, + "replicas": 2, + "revisionHistoryLimit": 10, + "selector": { + "matchLabels": { + "app": "dice-roller-canary" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": "25%", + "maxUnavailable": "25%" + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "dice-roller-canary", + "backstage.io/kubernetes-id": "dice-roller" + } + }, + "spec": { + "containers": [ + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "side-car", + "ports": [ + { + "containerPort": 81, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + }, + { + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "other-side-car", + "ports": [ + { + "containerPort": 82, + "protocol": "TCP" + } + ], + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File" + } + ], + "dnsPolicy": "ClusterFirst", + "restartPolicy": "Always", + "schedulerName": "default-scheduler", + "securityContext": {}, + "terminationGracePeriodSeconds": 30 + } + } + }, + "status": { + "conditions": [ + { + "lastTransitionTime": "2020-09-25T09:02:53.000Z", + "lastUpdateTime": "2020-09-25T10:34:04.000Z", + "message": "ReplicaSet \"dice-roller-canary-7d64cd756c\" has successfully progressed.", + "reason": "NewReplicaSetAvailable", + "status": "True", + "type": "Progressing" + }, + { + "lastTransitionTime": "2020-09-25T13:48:06.000Z", + "lastUpdateTime": "2020-09-25T13:48:06.000Z", + "message": "Deployment does not have minimum availability.", + "reason": "MinimumReplicasUnavailable", + "status": "False", + "type": "Available" + } + ], + "observedGeneration": 3, + "replicas": 2, + "unavailableReplicas": 2, + "updatedReplicas": 2 + } + } + ] +} diff --git a/plugins/kubernetes/src/components/DeploymentTables/index.ts b/plugins/kubernetes/src/components/DeploymentTables/index.ts new file mode 100644 index 0000000000..dac38c5c81 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentTables/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 { DeploymentTables } from './DeploymentTables'; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx new file mode 100644 index 0000000000..eb5d407c76 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.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 from 'react'; +import { render } from '@testing-library/react'; +import * as hpaFixture from './__fixtures__/horizontalpodautoscalers.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { HorizontalPodAutoscalers } from './HorizontalPodAutoscalers'; + +describe('HorizontalPodAutoscalers', () => { + it('should render horizontalpodautoscaler', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('Horizontal Pod Autoscaler')).toBeInTheDocument(); + + expect(getByText('Scaling Target')).toBeInTheDocument(); + expect(getByText('Api Version: apps/v1')).toBeInTheDocument(); + expect(getByText('Kind: Deployment')).toBeInTheDocument(); + expect(getByText('Name: dice-roller')).toBeInTheDocument(); + expect(getByText('Min Replicas')).toBeInTheDocument(); + expect(getByText('Max Replicas')).toBeInTheDocument(); + expect(getByText('15')).toBeInTheDocument(); + expect(getByText('Current Replicas')).toBeInTheDocument(); + expect(getByText('Desired Replicas')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('Target CPU Utilization Percentage')).toBeInTheDocument(); + expect(getByText('50')).toBeInTheDocument(); + expect(getByText('Current CPU Utilization Percentage')).toBeInTheDocument(); + expect(getByText('Last Scale Time')).toBeInTheDocument(); + expect(getAllByText('unknown')).toHaveLength(2); + expect(getAllByText('10')).toHaveLength(2); + }); +}); diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx new file mode 100644 index 0000000000..b4540840d2 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx @@ -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 React from 'react'; +import { Grid } from '@material-ui/core'; +import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core'; +import { orUnknown } from '../../utils'; + +type HorizontalPodAutoscalersProps = { + hpas: V1HorizontalPodAutoscaler[]; + children?: React.ReactNode; +}; + +export const HorizontalPodAutoscalers = ({ + hpas, +}: HorizontalPodAutoscalersProps) => { + return ( + + {hpas.map((hpa, i) => { + return ( + + +
+ +
+
+
+ ); + })} +
+ ); +}; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json new file mode 100644 index 0000000000..6bc6028246 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json @@ -0,0 +1,81 @@ +[ + { + "metadata": { + "annotations": { + "autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: unable to fetch metrics from resource metrics API: the server could not find the requested resource (get pods.metrics.k8s.io)\"}]", + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n" + }, + "creationTimestamp": "2020-09-28T13:28:00.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "autoscaling/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + "f:autoscaling.alpha.kubernetes.io/conditions": {} + } + }, + "f:status": { + "f:currentReplicas": {} + } + }, + "manager": "kube-controller-manager", + "operation": "Update", + "time": "2020-09-28T13:28:15.000Z" + }, + { + "apiVersion": "autoscaling/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:maxReplicas": {}, + "f:minReplicas": {}, + "f:scaleTargetRef": { + "f:apiVersion": {}, + "f:kind": {}, + "f:name": {} + }, + "f:targetCPUUtilizationPercentage": {} + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-28T13:28:21.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "698957", + "selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller", + "uid": "a70c8a90-5605-4d7d-adea-05cfb8d9d446" + }, + "spec": { + "maxReplicas": 15, + "minReplicas": 10, + "scaleTargetRef": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "dice-roller" + }, + "targetCPUUtilizationPercentage": 50 + }, + "status": { + "currentReplicas": 10, + "desiredReplicas": 0 + } + } +] diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts new file mode 100644 index 0000000000..3eebbe9813 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/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 { HorizontalPodAutoscalers } from './HorizontalPodAutoscalers'; diff --git a/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx b/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx new file mode 100644 index 0000000000..f2507a3f39 --- /dev/null +++ b/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx @@ -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 React from 'react'; +import { render } from '@testing-library/react'; +import * as ingressesFixture from './__fixtures__/ingress.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Ingresses } from './Ingresses'; + +describe('Ingresses', () => { + it('should render ingress', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('Ingress')).toBeInTheDocument(); + + // values + expect(getByText('Backend')).toBeInTheDocument(); + expect(getByText('Ip: 192.168.64.2')).toBeInTheDocument(); + expect(getByText('Rules')).toBeInTheDocument(); + expect(getByText('Host: nginx')).toBeInTheDocument(); + expect(getByText('Http:')).toBeInTheDocument(); + expect(getByText('Paths:')).toBeInTheDocument(); + expect(getByText('Service Name: dice-roller')).toBeInTheDocument(); + expect(getByText('Service Port: 80')).toBeInTheDocument(); + expect(getByText('Path: /')).toBeInTheDocument(); + expect(getByText('Path Type: ImplementationSpecific')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx b/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx new file mode 100644 index 0000000000..37932601a1 --- /dev/null +++ b/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid } from '@material-ui/core'; +import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core'; + +type IngressesProps = { + ingresses: ExtensionsV1beta1Ingress[]; + children?: React.ReactNode; +}; + +export const Ingresses = ({ ingresses }: IngressesProps) => { + return ( + + {ingresses.map((ingress, i) => { + return ( + + +
+ +
+
+
+ ); + })} +
+ ); +}; diff --git a/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json b/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json new file mode 100644 index 0000000000..fd0bc5ec43 --- /dev/null +++ b/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json @@ -0,0 +1,87 @@ +[ + { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"networking.k8s.io/v1beta1\",\"kind\":\"Ingress\",\"metadata\":{\"annotations\":{\"nginx.ingress.kubernetes.io/rewrite-target\":\"/$1\"},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"rules\":[{\"host\":\"nginx\",\"http\":{\"paths\":[{\"backend\":{\"serviceName\":\"dice-roller\",\"servicePort\":80},\"path\":\"/\"}]}}]}}\n", + "nginx.ingress.kubernetes.io/rewrite-target": "/$1" + }, + "creationTimestamp": "2020-09-28T13:28:00.000Z", + "generation": 1, + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "networking.k8s.io/v1beta1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {}, + "f:nginx.ingress.kubernetes.io/rewrite-target": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:rules": {} + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-28T13:28:21.000Z" + }, + { + "apiVersion": "networking.k8s.io/v1beta1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:loadBalancer": { + "f:ingress": {} + } + } + }, + "manager": "nginx-ingress-controller", + "operation": "Update", + "time": "2020-09-28T13:28:40.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "699017", + "selfLink": "/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/dice-roller", + "uid": "e96994c0-49b9-4c1c-8ce0-72c5336fe960" + }, + "spec": { + "rules": [ + { + "host": "nginx", + "http": { + "paths": [ + { + "backend": { + "serviceName": "dice-roller", + "servicePort": 80 + }, + "path": "/", + "pathType": "ImplementationSpecific" + } + ] + } + } + ] + }, + "status": { + "loadBalancer": { + "ingress": [ + { + "ip": "192.168.64.2" + } + ] + } + } + } +] diff --git a/plugins/kubernetes/src/components/Ingresses/index.ts b/plugins/kubernetes/src/components/Ingresses/index.ts new file mode 100644 index 0000000000..ecc67480db --- /dev/null +++ b/plugins/kubernetes/src/components/Ingresses/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 { Ingresses } from './Ingresses'; diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx new file mode 100644 index 0000000000..6985423a10 --- /dev/null +++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.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 React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ErrorPanel } from './ErrorPanel'; + +describe('ErrorPanel', () => { + it('render with error message', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect( + getByText( + 'There was an error retrieving some Kubernetes resources for the entity: THIS_ENTITY', + ), + ).toBeInTheDocument(); + + // message + expect(getByText('Errors: SOME_ERROR_MESSAGE')).toBeInTheDocument(); + }); + it('render with cluster errors', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + // title + expect( + getByText( + 'There was an error retrieving some Kubernetes resources for the entity: THIS_ENTITY', + ), + ).toBeInTheDocument(); + + // message + expect(getByText('Errors:')).toBeInTheDocument(); + expect(getByText('Cluster: THIS_CLUSTER')).toBeInTheDocument(); + expect( + getByText( + "Error fetching Kubernetes resource: 'some/resource', error: SYSTEM_ERROR", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx new file mode 100644 index 0000000000..1fe5714b09 --- /dev/null +++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx @@ -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. + */ + +import React from 'react'; +import { WarningPanel } from '@backstage/core'; +import { Typography } from '@material-ui/core'; +import { ClusterObjects } from '../../../../kubernetes-backend/src'; + +const clustersWithErrorsToErrorMessage = ( + clustersWithErrors: ClusterObjects[], +): React.ReactNode => { + return clustersWithErrors.map((c, i) => { + return ( +
+ {`Cluster: ${c.cluster.name}`} + {c.errors.map((e, j) => { + return ( + + {`Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}`} + + ); + })} +
+
+ ); + }); +}; + +type ErrorPanelProps = { + entityName: string; + errorMessage?: string; + clustersWithErrors?: ClusterObjects[]; + children?: React.ReactNode; +}; + +export const ErrorPanel = ({ + entityName, + errorMessage, + clustersWithErrors, +}: ErrorPanelProps) => ( + + {clustersWithErrors && ( +
Errors: {clustersWithErrorsToErrorMessage(clustersWithErrors)}
+ )} + {errorMessage && ( + Errors: {errorMessage} + )} +
+); diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx new file mode 100644 index 0000000000..013c7ad002 --- /dev/null +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -0,0 +1,250 @@ +/* + * 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, { ReactElement, useEffect, useState } from 'react'; +import { Grid, TabProps } from '@material-ui/core'; +import { Config } from '@backstage/config'; +import { + CardTab, + configApiRef, + Content, + Page, + pageTheme, + Progress, + TabbedCard, + useApi, +} from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { kubernetesApiRef } from '../../api/types'; +import { + AuthRequestBody, + ClusterObjects, + FetchResponse, + ObjectsByServiceIdResponse, +} from '@backstage/plugin-kubernetes-backend'; +import { kubernetesAuthProvidersApiRef } from '../../kubernetes-auth-provider/types'; +import { DeploymentTables } from '../DeploymentTables'; +import { DeploymentTriple } from '../../types/types'; +import { + ExtensionsV1beta1Ingress, + V1ConfigMap, + V1HorizontalPodAutoscaler, + V1Service, +} from '@kubernetes/client-node'; +import { Services } from '../Services'; +import { ConfigMaps } from '../ConfigMaps'; +import { Ingresses } from '../Ingresses'; +import { HorizontalPodAutoscalers } from '../HorizontalPodAutoscalers'; +import { ErrorPanel } from './ErrorPanel'; + +interface GroupedResponses extends DeploymentTriple { + services: V1Service[]; + configMaps: V1ConfigMap[]; + horizontalPodAutoscalers: V1HorizontalPodAutoscaler[]; + ingresses: ExtensionsV1beta1Ingress[]; +} + +// TODO this could probably be a lodash groupBy +const groupResponses = (fetchResponse: FetchResponse[]) => { + return fetchResponse.reduce( + (prev, next) => { + switch (next.type) { + case 'deployments': + prev.deployments.push(...next.resources); + break; + case 'pods': + prev.pods.push(...next.resources); + break; + case 'replicasets': + prev.replicaSets.push(...next.resources); + break; + case 'services': + prev.services.push(...next.resources); + break; + case 'configmaps': + prev.configMaps.push(...next.resources); + break; + case 'horizontalpodautoscalers': + prev.horizontalPodAutoscalers.push(...next.resources); + break; + case 'ingresses': + prev.ingresses.push(...next.resources); + break; + default: + } + return prev; + }, + { + pods: [], + replicaSets: [], + deployments: [], + services: [], + configMaps: [], + horizontalPodAutoscalers: [], + ingresses: [], + } as GroupedResponses, + ); +}; + +type KubernetesContentProps = { entity: Entity; children?: React.ReactNode }; + +export const KubernetesContent = ({ entity }: KubernetesContentProps) => { + const kubernetesApi = useApi(kubernetesApiRef); + + const [kubernetesObjects, setKubernetesObjects] = useState< + ObjectsByServiceIdResponse | undefined + >(undefined); + const [error, setError] = useState(undefined); + + const configApi = useApi(configApiRef); + const clusters: Config[] = configApi.getConfigArray('kubernetes.clusters'); + const allAuthProviders: string[] = clusters.map(c => + c.getString('authProvider'), + ); + const authProviders: string[] = [...new Set(allAuthProviders)]; + + const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef); + + useEffect(() => { + (async () => { + // For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider + let requestBody: AuthRequestBody = {}; + for (const authProviderStr of authProviders) { + // Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously + requestBody = await kubernetesAuthProvidersApi.decorateRequestBodyForAuth( + authProviderStr, + requestBody, + ); + } + + // TODO: Add validation on contents/format of requestBody + kubernetesApi + .getObjectsByServiceId(entity.metadata.name, requestBody) + .then(result => { + setKubernetesObjects(result); + }) + .catch(e => { + setError(e.message); + }); + })(); + /* eslint-disable react-hooks/exhaustive-deps */ + }, [entity.metadata.name, kubernetesApi, kubernetesAuthProvidersApi]); + /* eslint-enable react-hooks/exhaustive-deps */ + + const clustersWithErrors = + kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? []; + + return ( + + + + {kubernetesObjects === undefined && error === undefined && ( + + )} + + {/* errors retrieved from the kubernetes clusters */} + {clustersWithErrors.length > 0 && ( + + )} + + {/* other errors */} + {error !== undefined && ( + + )} + + {kubernetesObjects?.items.map((item, i) => ( + + + + ))} + + + + ); +}; + +type ClusterProps = { + clusterObjects: ClusterObjects; + children?: React.ReactNode; +}; + +const Cluster = ({ clusterObjects }: ClusterProps) => { + const [selectedTab, setSelectedTab] = useState('one'); + + const handleChange = (_ev: any, newSelectedTab: string | number) => + setSelectedTab(newSelectedTab); + + const groupedResponses = groupResponses(clusterObjects.resources); + + const configMaps = groupedResponses.configMaps; + const hpas = groupedResponses.horizontalPodAutoscalers; + const ingresses = groupedResponses.ingresses; + + const tabs: ReactElement[] = [ + + + , + + + , + ]; + + if (configMaps.length > 0) { + tabs.push( + + + , + ); + } + if (hpas.length > 0) { + tabs.push( + + + , + ); + } + if (ingresses.length > 0) { + tabs.push( + + + , + ); + } + + return ( + <> + + {tabs} + + + ); +}; diff --git a/plugins/kubernetes/src/components/KubernetesContent/index.ts b/plugins/kubernetes/src/components/KubernetesContent/index.ts new file mode 100644 index 0000000000..dc10b08927 --- /dev/null +++ b/plugins/kubernetes/src/components/KubernetesContent/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 { KubernetesContent } from './KubernetesContent'; diff --git a/plugins/kubernetes/src/components/Services/Services.test.tsx b/plugins/kubernetes/src/components/Services/Services.test.tsx new file mode 100644 index 0000000000..5e74499bc5 --- /dev/null +++ b/plugins/kubernetes/src/components/Services/Services.test.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 React from 'react'; +import { render } from '@testing-library/react'; +import { Services } from './Services'; +import * as servicesFixture from './__fixtures__/services.json'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('Services', () => { + it('should render 2 services', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp(), + ); + + // common elements + expect(getAllByText('Service')).toHaveLength(2); + expect(getAllByText('Ports')).toHaveLength(2); + expect(getAllByText('Type')).toHaveLength(2); + expect(getAllByText('Protocol: TCP')).toHaveLength(3); + + // service 1 + expect(getByText('dice-roller')).toBeInTheDocument(); + expect(getByText('ClusterIP')).toBeInTheDocument(); + + expect(getByText('Name: port1')).toBeInTheDocument(); + expect(getByText('Port: 80')).toBeInTheDocument(); + expect(getByText('Target Port: 9376')).toBeInTheDocument(); + expect(getByText('Name: port1')).toBeInTheDocument(); + expect(getByText('Port: 81')).toBeInTheDocument(); + expect(getByText('Target Port: 9377')).toBeInTheDocument(); + expect(getByText('10.102.223.105')).toBeInTheDocument(); + + // service 2 + expect(getByText('dice-roller-lb')).toBeInTheDocument(); + expect(getByText('LoadBalancer')).toBeInTheDocument(); + expect(getByText('Node Port: 32276')).toBeInTheDocument(); + expect(getByText('Port: 8765')).toBeInTheDocument(); + expect(getByText('Target Port: 9378')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/Services/Services.tsx b/plugins/kubernetes/src/components/Services/Services.tsx new file mode 100644 index 0000000000..d0a4f71eae --- /dev/null +++ b/plugins/kubernetes/src/components/Services/Services.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 { Grid } from '@material-ui/core'; +import { V1Service } from '@kubernetes/client-node'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core'; + +type ServicesProps = { + services: V1Service[]; + children?: React.ReactNode; +}; + +export const Services = ({ services }: ServicesProps) => { + return ( + + {services.map((s, i) => { + const metadata: any = {}; + + if (s.status?.loadBalancer?.ingress?.length ?? -1 > 0) { + metadata.loadbalancer = s.status?.loadBalancer; + } + + if (s.spec?.type === 'ClusterIP') { + metadata.clusterIP = s.spec.clusterIP; + } + + return ( + + +
+ +
+
+
+ ); + })} +
+ ); +}; diff --git a/plugins/kubernetes/src/components/Services/__fixtures__/services.json b/plugins/kubernetes/src/components/Services/__fixtures__/services.json new file mode 100644 index 0000000000..7f9b49a9f6 --- /dev/null +++ b/plugins/kubernetes/src/components/Services/__fixtures__/services.json @@ -0,0 +1,164 @@ +[ + { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"name\":\"port1\",\"port\":80,\"protocol\":\"TCP\",\"targetPort\":9376},{\"name\":\"port2\",\"port\":81,\"protocol\":\"TCP\",\"targetPort\":9377}],\"selector\":{\"app\":\"dice-roller\"}}}\n" + }, + "creationTimestamp": "2020-09-23T12:00:55.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:ports": { + ".": {}, + "k:{\"port\":80,\"protocol\":\"TCP\"}": { + ".": {}, + "f:name": {}, + "f:port": {}, + "f:protocol": {}, + "f:targetPort": {} + }, + "k:{\"port\":81,\"protocol\":\"TCP\"}": { + ".": {}, + "f:name": {}, + "f:port": {}, + "f:protocol": {}, + "f:targetPort": {} + } + }, + "f:selector": { + ".": {}, + "f:app": {} + }, + "f:sessionAffinity": {}, + "f:type": {} + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-28T08:50:11.000Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "665838", + "selfLink": "/api/v1/namespaces/default/services/dice-roller", + "uid": "ae9aff92-a525-4bc9-82dc-a0537bf8034c" + }, + "spec": { + "clusterIP": "10.102.223.105", + "ports": [ + { + "name": "port1", + "port": 80, + "protocol": "TCP", + "targetPort": 9376 + }, + { + "name": "port2", + "port": 81, + "protocol": "TCP", + "targetPort": 9377 + } + ], + "selector": { + "app": "dice-roller" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + }, + { + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"kind\":\"Service\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller-lb\",\"namespace\":\"default\"},\"spec\":{\"ports\":[{\"port\":8765,\"targetPort\":9376}],\"selector\":{\"app\":\"dice-roller\"},\"type\":\"LoadBalancer\"}}\n" + }, + "creationTimestamp": "2020-09-28T08:51:21.000Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "apiVersion": "v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:backstage.io/kubernetes-id": {} + } + }, + "f:spec": { + "f:externalTrafficPolicy": {}, + "f:ports": { + ".": {}, + "k:{\"port\":8765,\"protocol\":\"TCP\"}": { + ".": {}, + "f:port": {}, + "f:protocol": {}, + "f:targetPort": {} + } + }, + "f:selector": { + ".": {}, + "f:app": {} + }, + "f:sessionAffinity": {}, + "f:type": {} + } + }, + "manager": "kubectl", + "operation": "Update", + "time": "2020-09-28T08:51:21.000Z" + } + ], + "name": "dice-roller-lb", + "namespace": "default", + "resourceVersion": "665998", + "selfLink": "/api/v1/namespaces/default/services/dice-roller-lb", + "uid": "5554da3b-2041-4403-8cf4-cd2ccae760f8" + }, + "spec": { + "clusterIP": "10.99.205.233", + "externalTrafficPolicy": "Cluster", + "ports": [ + { + "nodePort": 32276, + "port": 8765, + "protocol": "TCP", + "targetPort": 9378 + } + ], + "selector": { + "app": "dice-roller" + }, + "sessionAffinity": "None", + "type": "LoadBalancer" + }, + "status": { + "loadBalancer": {} + } + } +] diff --git a/plugins/kubernetes/src/components/Services/index.ts b/plugins/kubernetes/src/components/Services/index.ts new file mode 100644 index 0000000000..d52ebf5f14 --- /dev/null +++ b/plugins/kubernetes/src/components/Services/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 { Services } from './Services'; diff --git a/plugins/kubernetes/src/index.ts b/plugins/kubernetes/src/index.ts new file mode 100644 index 0000000000..8b5969666c --- /dev/null +++ b/plugins/kubernetes/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 { Router } from './Router'; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts new file mode 100644 index 0000000000..7de5c7c0a9 --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -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 { OAuthApi } from '@backstage/core'; +import { KubernetesAuthProvider } from './types'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { + authProvider: OAuthApi; + + constructor(authProvider: OAuthApi) { + this.authProvider = authProvider; + } + + async decorateRequestBodyForAuth( + requestBody: AuthRequestBody, + ): Promise { + const googleAuthToken: string = await this.authProvider.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform', + ); + if ('auth' in requestBody) { + requestBody.auth!.google = googleAuthToken; + } else { + requestBody.auth = { google: googleAuthToken }; + } + return requestBody; + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts new file mode 100644 index 0000000000..ac909d6695 --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.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. + */ + +import { OAuthApi } from '@backstage/core'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; +import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; +import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; +import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider'; + +export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { + private readonly kubernetesAuthProviderMap: Map< + string, + KubernetesAuthProvider + >; + + constructor(options: { googleAuthApi: OAuthApi }) { + this.kubernetesAuthProviderMap = new Map(); + this.kubernetesAuthProviderMap.set( + 'google', + new GoogleKubernetesAuthProvider(options.googleAuthApi), + ); + this.kubernetesAuthProviderMap.set( + 'serviceAccount', + new ServiceAccountKubernetesAuthProvider(), + ); + } + + async decorateRequestBodyForAuth( + authProvider: string, + requestBody: AuthRequestBody, + ): Promise { + const kubernetesAuthProvider: + | KubernetesAuthProvider + | undefined = this.kubernetesAuthProviderMap.get(authProvider); + if (kubernetesAuthProvider) { + return await kubernetesAuthProvider.decorateRequestBodyForAuth( + requestBody, + ); + } + throw new Error( + `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`, + ); + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.ts new file mode 100644 index 0000000000..3ac5a9494b --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServiceAccountKubernetesAuthProvider.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 { KubernetesAuthProvider } from './types'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export class ServiceAccountKubernetesAuthProvider + implements KubernetesAuthProvider { + async decorateRequestBodyForAuth( + requestBody: AuthRequestBody, + ): Promise { + // No-op, with service account for auth, cluster config/details should already have serviceAccountToken + return requestBody; + } +} diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts new file mode 100644 index 0000000000..24fee0f94c --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.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 { createApiRef } from '@backstage/core'; +import { AuthRequestBody } from '@backstage/plugin-kubernetes-backend'; + +export interface KubernetesAuthProvider { + decorateRequestBodyForAuth( + requestBody: AuthRequestBody, + ): Promise; +} + +export const kubernetesAuthProvidersApiRef = createApiRef< + KubernetesAuthProvidersApi +>({ + id: 'plugin.kubernetes-auth-providers.service', + description: 'Used by the Kubernetes plugin to fetch KubernetesAuthProviders', +}); + +export interface KubernetesAuthProvidersApi { + decorateRequestBodyForAuth( + authProvider: string, + requestBody: AuthRequestBody, + ): Promise; +} diff --git a/plugins/kubernetes/src/plugin.test.ts b/plugins/kubernetes/src/plugin.test.ts new file mode 100644 index 0000000000..20c50a813a --- /dev/null +++ b/plugins/kubernetes/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('kubernetes', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts new file mode 100644 index 0000000000..cff9e1468b --- /dev/null +++ b/plugins/kubernetes/src/plugin.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 { + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, + googleAuthApiRef, +} from '@backstage/core'; +import { KubernetesBackendClient } from './api/KubernetesBackendClient'; +import { kubernetesApiRef } from './api/types'; +import { kubernetesAuthProvidersApiRef } from './kubernetes-auth-provider/types'; +import { KubernetesAuthProviders } from './kubernetes-auth-provider/KubernetesAuthProviders'; + +export const rootCatalogKubernetesRouteRef = createRouteRef({ + path: '*', + title: 'Kubernetes', +}); + +export const plugin = createPlugin({ + id: 'kubernetes', + apis: [ + createApiFactory({ + api: kubernetesApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => + new KubernetesBackendClient({ discoveryApi }), + }), + createApiFactory({ + api: kubernetesAuthProvidersApiRef, + deps: { googleAuthApi: googleAuthApiRef }, + factory: ({ googleAuthApi }) => { + return new KubernetesAuthProviders({ googleAuthApi }); + }, + }), + ], +}); diff --git a/plugins/kubernetes/src/setupTests.ts b/plugins/kubernetes/src/setupTests.ts new file mode 100644 index 0000000000..4b4cdbdaaf --- /dev/null +++ b/plugins/kubernetes/src/setupTests.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. + */ +import '@testing-library/jest-dom'; + +require('jest-fetch-mock').enableMocks(); diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts new file mode 100644 index 0000000000..09bff511a7 --- /dev/null +++ b/plugins/kubernetes/src/types/types.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 { V1Deployment, V1Pod, V1ReplicaSet } from '@kubernetes/client-node'; + +export interface DeploymentTriple { + pods: V1Pod[]; + replicaSets: V1ReplicaSet[]; + deployments: V1Deployment[]; +} diff --git a/plugins/kubernetes/src/utils.ts b/plugins/kubernetes/src/utils.ts new file mode 100644 index 0000000000..e038b1af8b --- /dev/null +++ b/plugins/kubernetes/src/utils.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Display the value unknown rather than 'undefined' +export function orUnknown(val: T | undefined): T | string { + return val ?? 'unknown'; +} diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index c5522ae67a..0b30e48689 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.22", - "@backstage/core": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@backstage/config": "^0.1.1-alpha.24", + "@backstage/core": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -34,15 +34,17 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", - "@backstage/test-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", + "@backstage/test-utils": "^0.1.1-alpha.24", "@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" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" }, "files": [ "dist" diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 0cadf440f7..603d01d62b 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-newrelic", - "version": "0.1.1-alpha.22", + "version": "0.1.1-alpha.24", "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.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@backstage/core": "^0.1.1-alpha.24", + "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -31,14 +31,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", "@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" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" }, "files": [ "dist" diff --git a/plugins/newrelic/src/assets/img/newrelic-plugin-apm.png b/plugins/newrelic/src/assets/img/newrelic-plugin-apm.png index cc63306bba..88661830d0 100644 Binary files a/plugins/newrelic/src/assets/img/newrelic-plugin-apm.png and b/plugins/newrelic/src/assets/img/newrelic-plugin-apm.png differ diff --git a/plugins/newrelic/src/assets/img/newrelic-plugin-tools.png b/plugins/newrelic/src/assets/img/newrelic-plugin-tools.png index e42013f14c..dc35d05dca 100644 Binary files a/plugins/newrelic/src/assets/img/newrelic-plugin-tools.png and b/plugins/newrelic/src/assets/img/newrelic-plugin-tools.png differ diff --git a/plugins/newrelic/src/assets/img/newrelic.jpg b/plugins/newrelic/src/assets/img/newrelic.jpg index d4b3a0e196..914ad02280 100644 Binary files a/plugins/newrelic/src/assets/img/newrelic.jpg and b/plugins/newrelic/src/assets/img/newrelic.jpg differ diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index fb1b82f3ea..723c3e395e 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.22", + "version": "0.1.1-alpha.24", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,8 +19,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.22", - "@backstage/config": "^0.1.1-alpha.22", + "@backstage/backend-common": "^0.1.1-alpha.24", + "@backstage/config": "^0.1.1-alpha.24", "@types/express": "^4.17.6", "@types/http-proxy-middleware": "^0.19.3", "express": "^4.17.1", @@ -35,7 +35,7 @@ "yup": "^0.29.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", "@types/node-fetch": "^2.5.7", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index c7d7fff1d4..07737e9ea8 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -17,16 +17,20 @@ import { createRouter } from './router'; import * as winston from 'winston'; import { ConfigReader } from '@backstage/config'; -import { loadBackendConfig } from '@backstage/backend-common'; +import { + loadBackendConfig, + SingleHostDiscovery, +} from '@backstage/backend-common'; describe('createRouter', () => { it('works', async () => { const logger = winston.createLogger(); const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ config, logger, - pathPrefix: '/proxy', + discovery, }); expect(router).toBeDefined(); }); diff --git a/plugins/proxy-backend/src/service/router.ts b/plugins/proxy-backend/src/service/router.ts index 7cd5f37598..8cec31719c 100644 --- a/plugins/proxy-backend/src/service/router.ts +++ b/plugins/proxy-backend/src/service/router.ts @@ -23,12 +23,12 @@ import createProxyMiddleware, { } from 'http-proxy-middleware'; import { Logger } from 'winston'; import http from 'http'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; export interface RouterOptions { logger: Logger; config: Config; - // The URL path prefix that the router itself is mounted as, commonly "/proxy" - pathPrefix: string; + discovery: PluginEndpointDiscovery; } export interface ProxyConfig extends ProxyMiddlewareConfig { @@ -76,16 +76,14 @@ export async function createRouter( ): Promise { const router = Router(); + const externalUrl = await options.discovery.getExternalBaseUrl('proxy'); + const { pathname: pathPrefix } = new URL(externalUrl); + const proxyConfig = options.config.getOptional('proxy') ?? {}; Object.entries(proxyConfig).forEach(([route, proxyRouteConfig]) => { router.use( route, - buildMiddleware( - options.pathPrefix, - options.logger, - route, - proxyRouteConfig, - ), + buildMiddleware(pathPrefix, options.logger, route, proxyRouteConfig), ); }); diff --git a/plugins/proxy-backend/src/service/standaloneServer.ts b/plugins/proxy-backend/src/service/standaloneServer.ts index 68e9ade183..83980729d8 100644 --- a/plugins/proxy-backend/src/service/standaloneServer.ts +++ b/plugins/proxy-backend/src/service/standaloneServer.ts @@ -17,6 +17,7 @@ import { createServiceBuilder, loadBackendConfig, + SingleHostDiscovery, } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -37,10 +38,11 @@ export async function startStandaloneServer( logger.debug('Creating application...'); const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ config, logger, - pathPrefix: '/proxy', + discovery, }); const service = createServiceBuilder(module) .enableCors({ origin: 'http://localhost:3000' }) diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 8ccaae9914..9dbd598dee 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.22", + "version": "0.1.1-alpha.24", "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.22", - "@backstage/core": "^0.1.1-alpha.22", - "@backstage/plugin-catalog": "^0.1.1-alpha.22", - "@backstage/theme": "^0.1.1-alpha.22", + "@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", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -36,14 +36,16 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.22", - "@backstage/dev-utils": "^0.1.1-alpha.22", + "@backstage/cli": "^0.1.1-alpha.24", + "@backstage/dev-utils": "^0.1.1-alpha.24", "@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" + "jest-fetch-mock": "^3.0.3", + "msw": "^0.20.5", + "node-fetch": "^2.6.1" }, "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 bb7faabc9d..10c3d9fcd1 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx @@ -14,53 +14,38 @@ * limitations under the License. */ -import { cleanup, fireEvent, render } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { act } from 'react-dom/test-utils'; -import RegisterComponentForm, { Props } from './RegisterComponentForm'; +import { RegisterComponentForm } from './RegisterComponentForm'; -const setup = (props?: Partial) => { - return { - rendered: render( - , - ), - }; -}; describe('RegisterComponentForm', () => { - afterEach(() => cleanup()); - it('should initially render a disabled button', async () => { - const { rendered } = setup(); + render(); + expect( - await rendered.findByText( - 'Enter the full path to the component.yaml file in GitHub, GitLab, Bitbucket or Azure to start tracking your component.', - ), + await screen.findByText(/Enter the full path to the catalog-info.yaml/), ).toBeInTheDocument(); - const submit = (await rendered.getByRole('button')) as HTMLButtonElement; - expect(submit.disabled).toBeTruthy(); + expect(screen.getByText('Submit').closest('button')).toBeDisabled(); }); - it('should enable a submit form when data when component url is set ', async () => { - const { rendered } = setup(); - const input = (await rendered.getByRole('textbox')) as HTMLInputElement; + it('should enable a submit button when the target url is set ', async () => { + render(); + await act(async () => { - // react-hook-form uses `input` event for changes - fireEvent.input(input, { - target: { value: 'https://example.com/blob/master/component.yaml' }, - }); + await userEvent.type( + await screen.findByLabelText('Entity file URL', { exact: false }), + 'https://example.com/blob/master/component.yaml', + ); }); - const submit = (await rendered.getByRole('button')) as HTMLButtonElement; - expect(submit.disabled).toBeFalsy(); + expect(screen.getByText('Submit').closest('button')).not.toBeDisabled(); + }); + + it('should show spinner while submitting', async () => { + render(); + + expect(screen.getByTestId('loading-progress')).toBeInTheDocument(); }); }); - -it('should show spinner while submitting', async () => { - const { rendered } = setup({ submitting: true }); - expect(rendered.getByTestId('loading-progress')).toBeInTheDocument(); -}); diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx index 4090b2af9c..bc433e1c36 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx @@ -19,12 +19,15 @@ import { Button, FormControl, FormHelperText, + InputLabel, LinearProgress, + MenuItem, + Select, TextField, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import React, { FC } from 'react'; -import { useForm } from 'react-hook-form'; +import React from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { ComponentIdValidators } from '../../util/validate'; const useStyles = makeStyles(theme => ({ @@ -36,15 +39,18 @@ const useStyles = makeStyles(theme => ({ submit: { marginTop: theme.spacing(1), }, + select: { + minWidth: 120, + }, })); export type Props = { onSubmit: (formData: Record) => Promise; - submitting: boolean; + submitting?: boolean; }; -const RegisterComponentForm: FC = ({ onSubmit, submitting }) => { - const { register, handleSubmit, errors, formState } = useForm({ +export const RegisterComponentForm = ({ onSubmit, submitting }: Props) => { + const { control, register, handleSubmit, errors, formState } = useForm({ mode: 'onChange', }); const classes = useStyles(); @@ -64,14 +70,14 @@ const RegisterComponentForm: FC = ({ onSubmit, submitting }) => { = ({ onSubmit, submitting }) => { )} + + + Host type + ( + + )} + /> +