diff --git a/.changeset/afraid-teachers-cross.md b/.changeset/afraid-teachers-cross.md new file mode 100644 index 0000000000..8fc376441f --- /dev/null +++ b/.changeset/afraid-teachers-cross.md @@ -0,0 +1,29 @@ +--- +'@backstage/create-app': patch +--- + +Pass on plugin database management instance that is now required by the scaffolder plugin. + +To apply this change to an existing application, add the following to `src/plugins/scaffolder.ts`: + +```diff +export default async function createPlugin({ + logger, + config, ++ database, +}: PluginEnvironment) { + +// ...omitted... + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + dockerClient, + entityClient, ++ database, + }); +} +``` diff --git a/.changeset/bitbucket-scaffolder.md b/.changeset/bitbucket-scaffolder.md deleted file mode 100644 index e1e6f17b29..0000000000 --- a/.changeset/bitbucket-scaffolder.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Add scaffolding support for Bitbucket Cloud and Server. diff --git a/.changeset/chilly-dodos-drop.md b/.changeset/chilly-dodos-drop.md deleted file mode 100644 index 4a03bb8ae1..0000000000 --- a/.changeset/chilly-dodos-drop.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/techdocs-common': patch -'@backstage/plugin-techdocs-backend': patch ---- - -1. Added option to use AWS S3 as a choice to store the static generated files for TechDocs. diff --git a/.changeset/cost-insights-tidy-geese-play.md b/.changeset/cost-insights-tidy-geese-play.md new file mode 100644 index 0000000000..f8aa503ca6 --- /dev/null +++ b/.changeset/cost-insights-tidy-geese-play.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Fix snooze quarter option diff --git a/.changeset/cuddly-files-argue.md b/.changeset/cuddly-files-argue.md deleted file mode 100644 index d495a945f4..0000000000 --- a/.changeset/cuddly-files-argue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Replace `register-component` plugin with new `catalog-import` plugin diff --git a/.changeset/curly-crabs-compete.md b/.changeset/curly-crabs-compete.md new file mode 100644 index 0000000000..552a41a9f1 --- /dev/null +++ b/.changeset/curly-crabs-compete.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-circleci': patch +--- + +Migrated to new composability API, exporting the plugin instance as `circleCIPlugin`, the entity page content as `EntityCircleCIContent`, and entity conditional as `isCircleCIAvailable`. diff --git a/.changeset/cyan-lies-flow.md b/.changeset/cyan-lies-flow.md new file mode 100644 index 0000000000..e7da9fab0a --- /dev/null +++ b/.changeset/cyan-lies-flow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': minor +--- + +Migrated to new composability API, exporting the plugin instance as `searchPlugin`, and page as `SearchPage`. Due to the old router component also being called `SearchPage`, this is a breaking change. The old page component is now exported as `Router`, which can be used to maintain the old behavior. diff --git a/.changeset/cyan-lizards-confess.md b/.changeset/cyan-lizards-confess.md deleted file mode 100644 index 17ff8f0cdf..0000000000 --- a/.changeset/cyan-lizards-confess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Fix HTTPS certificate generation and add new config switch, enabling it simply by setting `backend.https = true`. Also introduces caching of generated certificates in order to avoid having to add a browser override every time the backend is restarted. diff --git a/.changeset/dry-dolls-run.md b/.changeset/dry-dolls-run.md deleted file mode 100644 index 423d14d044..0000000000 --- a/.changeset/dry-dolls-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Use consistent file extensions for JS output when building packages. diff --git a/.changeset/early-hotels-mate.md b/.changeset/early-hotels-mate.md new file mode 100644 index 0000000000..2cda0344bf --- /dev/null +++ b/.changeset/early-hotels-mate.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +use child logger, if provided, to log single location refresh diff --git a/.changeset/early-spies-grow.md b/.changeset/early-spies-grow.md deleted file mode 100644 index 3591b1398a..0000000000 --- a/.changeset/early-spies-grow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Fixed a bug where the catalog would read back all entities when adding a location that already exists. diff --git a/.changeset/empty-cooks-laugh.md b/.changeset/empty-cooks-laugh.md new file mode 100644 index 0000000000..8f509f08cd --- /dev/null +++ b/.changeset/empty-cooks-laugh.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-backend': patch +--- + +Added a new `disableConfigInjection` option, which can be used to disable the configuration injection in environments where it can't be used. diff --git a/.changeset/famous-shrimps-double.md b/.changeset/famous-shrimps-double.md deleted file mode 100644 index 198c0dc8f5..0000000000 --- a/.changeset/famous-shrimps-double.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Bug fix: User can retry creating a new component if an error occurs, without having to reload the page. diff --git a/.changeset/fifty-yaks-behave.md b/.changeset/fifty-yaks-behave.md deleted file mode 100644 index 8f2fa2ec67..0000000000 --- a/.changeset/fifty-yaks-behave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/techdocs-common': patch ---- - -Fix for `integration.github.apiBaseUrl` configuration not properly overriding apiBaseUrl used by techdocs diff --git a/.changeset/flat-suns-call.md b/.changeset/flat-suns-call.md deleted file mode 100644 index 096a10ec30..0000000000 --- a/.changeset/flat-suns-call.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Add support for the majority of the Core configurations for Passport-SAML. - -These configuration keys are supported: - -- entryPoint -- issuer -- cert -- privateKey -- decryptionPvk -- signatureAlgorithm -- digestAlgorithm - -As part of this change, there is also a fix to the redirection behaviour when doing load balancing and HTTPS termination - the application's baseUrl is used to generate the callback URL. For properly configured Backstage installations, no changes are necessary, and the baseUrl is respected. diff --git a/.changeset/fluffy-hats-hug.md b/.changeset/fluffy-hats-hug.md new file mode 100644 index 0000000000..b3d06337fd --- /dev/null +++ b/.changeset/fluffy-hats-hug.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Don't respond to a request twice if an entity has not been found. diff --git a/.changeset/four-rings-push.md b/.changeset/four-rings-push.md new file mode 100644 index 0000000000..500dd0e812 --- /dev/null +++ b/.changeset/four-rings-push.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cloudbuild': patch +--- + +Migrate to new composability API, exporting the plugin instance as `cloudbuildPlugin`, the entity content as `EntityCloudbuildContent`, the entity conditional as `isCloudbuildAvailable`, and entity cards as `EntityLatestCloudbuildRunCard` and `EntityLatestCloudbuildsForBranchCard`. diff --git a/.changeset/friendly-numbers-accept.md b/.changeset/friendly-numbers-accept.md deleted file mode 100644 index 7890ec0c4a..0000000000 --- a/.changeset/friendly-numbers-accept.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@backstage/config-loader': patch ---- - -Deprecate `$data` and replace it with `$include` which allows for any type of json value to be read from external files. In addition, `$include` can be used without a path, which causes the value at the root of the file to be loaded. - -Most usages of `$data` can be directly replaced with `$include`, except if the referenced value is not a string, in which case the value needs to be changed. For example: - -```yaml -# app-config.yaml -foo: - $data: foo.yaml#myValue # replacing with $include will turn the value into a number - $data: bar.yaml#myValue # replacing with $include is safe - -# foo.yaml -myValue: 0xf00 - -# bar.yaml -myValue: bar -``` diff --git a/.changeset/funny-students-shout.md b/.changeset/funny-students-shout.md new file mode 100644 index 0000000000..97b273c687 --- /dev/null +++ b/.changeset/funny-students-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-register-component': patch +--- + +Migrated to new composability API, exporting the plugin instance as `registerComponentPlugin`, and page as `RegisterComponentPage`. diff --git a/.changeset/heavy-shirts-nail.md b/.changeset/heavy-shirts-nail.md deleted file mode 100644 index 0abe11afcf..0000000000 --- a/.changeset/heavy-shirts-nail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Change `location_update_log` columns from `nvarchar(255)` to `text` diff --git a/.changeset/hot-pants-taste.md b/.changeset/hot-pants-taste.md new file mode 100644 index 0000000000..5cce26c5bf --- /dev/null +++ b/.changeset/hot-pants-taste.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-api-docs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-lighthouse': patch +'@backstage/plugin-techdocs': patch +--- + +Improve display of error messages diff --git a/.changeset/hungry-mice-clap.md b/.changeset/hungry-mice-clap.md deleted file mode 100644 index 6b819620b0..0000000000 --- a/.changeset/hungry-mice-clap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-sentry': patch ---- - -Port to new composability API by exporting new `EntitySentryContent` and `EntitySentryCard` component extensions. diff --git a/.changeset/kind-buses-change.md b/.changeset/kind-buses-change.md deleted file mode 100644 index 1d93e27dff..0000000000 --- a/.changeset/kind-buses-change.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/techdocs-common': patch -'@backstage/plugin-scaffolder-backend': patch ---- - -Moving the Git actions to isomorphic-git instead of the node binding version of nodegit diff --git a/.changeset/large-guests-compete.md b/.changeset/large-guests-compete.md deleted file mode 100644 index 7f71673033..0000000000 --- a/.changeset/large-guests-compete.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Remove the unused dependency to `@backstage/plugin-techdocs`. diff --git a/.changeset/large-terms-invite.md b/.changeset/large-terms-invite.md new file mode 100644 index 0000000000..6653ac8e1e --- /dev/null +++ b/.changeset/large-terms-invite.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Introduced `v2` Scaffolder REST API, which uses an implementation that is database backed, making the scaffolder instances stateless. The `createRouter` function now requires a `PluginDatabaseManager` instance to be passed in, commonly available as `database` in the plugin environment in the backend. + +This API should be considered unstable until used by the scaffolder frontend. diff --git a/.changeset/loud-vans-pretend.md b/.changeset/loud-vans-pretend.md deleted file mode 100644 index 1e2498284b..0000000000 --- a/.changeset/loud-vans-pretend.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-search': patch ---- - -Fix Material-UI warning for search filtering diff --git a/.changeset/mighty-plums-wave.md b/.changeset/mighty-plums-wave.md deleted file mode 100644 index ac84b68e04..0000000000 --- a/.changeset/mighty-plums-wave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Tweaked development log formatter to include extra fields at the end of each log line diff --git a/.changeset/nasty-colts-tell.md b/.changeset/nasty-colts-tell.md deleted file mode 100644 index eeb7818f4c..0000000000 --- a/.changeset/nasty-colts-tell.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Add `backend:bundle` command for bundling a backend package with dependencies into a deployment archive. diff --git a/.changeset/new-horses-protect.md b/.changeset/new-horses-protect.md deleted file mode 100644 index 9d77926b13..0000000000 --- a/.changeset/new-horses-protect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Add processor for ingesting AWS accounts from AWS Organizations diff --git a/.changeset/ninety-houses-shout.md b/.changeset/ninety-houses-shout.md new file mode 100644 index 0000000000..845decc74c --- /dev/null +++ b/.changeset/ninety-houses-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Migrated to new composability API, exporting the plugin instance as `techdocsPlugin`, the top-level page as `TechdocsPage`, and the entity content as `EntityTechdocsContent`. diff --git a/.changeset/odd-buckets-compare.md b/.changeset/odd-buckets-compare.md new file mode 100644 index 0000000000..faf68fb4a0 --- /dev/null +++ b/.changeset/odd-buckets-compare.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Set explicit content-type in error handler responses. diff --git a/.changeset/odd-eyes-beg.md b/.changeset/odd-eyes-beg.md deleted file mode 100644 index 25a7699516..0000000000 --- a/.changeset/odd-eyes-beg.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/integration': patch ---- - -Provide support for on-prem azure devops diff --git a/.changeset/old-tools-exist.md b/.changeset/old-tools-exist.md new file mode 100644 index 0000000000..a9365cb784 --- /dev/null +++ b/.changeset/old-tools-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Slight refactoring in support of a future search implementation in `UrlReader`. Mostly moving code around. diff --git a/.changeset/perfect-ladybugs-listen.md b/.changeset/perfect-ladybugs-listen.md new file mode 100644 index 0000000000..2f9e65c1d9 --- /dev/null +++ b/.changeset/perfect-ladybugs-listen.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-pagerduty': patch +--- + +Migrated to new composability API, exporting the plugin instance as `pagerDutyPlugin`, entity card as `EntityPagerDutyCard`, and entity conditional as `isPagerDutyAvailable`. diff --git a/.changeset/poor-sheep-give.md b/.changeset/poor-sheep-give.md new file mode 100644 index 0000000000..4c52594b35 --- /dev/null +++ b/.changeset/poor-sheep-give.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': patch +--- + +Added `mountedRoutes` option to `wrapInTestApp`, allowing routes to be associated to concrete paths to make `useRouteRef` usable in tested components. diff --git a/.changeset/popular-cars-eat.md b/.changeset/popular-cars-eat.md new file mode 100644 index 0000000000..6d3bcdfab2 --- /dev/null +++ b/.changeset/popular-cars-eat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins': patch +--- + +Migrate to new composability API, exporting the plugin instance as `jenkinsPlugin`, the entity content as `EntityJenkinsContent`, the entity conditional as `isJenkinsAvailable`, and the entity card as `EntityLatestJenkinsRunCard`. diff --git a/.changeset/selfish-years-pump.md b/.changeset/selfish-years-pump.md new file mode 100644 index 0000000000..e5cc5f3bc3 --- /dev/null +++ b/.changeset/selfish-years-pump.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-github-actions': patch +--- + +Migrate to new composability API, exporting the plugin instance as `githubActionsPlugin`, the entity content as `EntityGithubActionsContent`, entity conditional as `isGithubActionsAvailable`, and entity cards as `EntityLatestGithubActionRunCard`, `EntityLatestGithubActionsForBranchCard`, and `EntityRecentGithubActionsRunsCard`. diff --git a/.changeset/shiny-falcons-marry.md b/.changeset/shiny-falcons-marry.md new file mode 100644 index 0000000000..4dda68d693 --- /dev/null +++ b/.changeset/shiny-falcons-marry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-newrelic': patch +--- + +Migrate to new composability API, exporting the plugin instance as `newRelicPlugin`, and the root page as `NewRelicPage`. diff --git a/.changeset/shiny-shirts-worry.md b/.changeset/shiny-shirts-worry.md new file mode 100644 index 0000000000..b4797b61b4 --- /dev/null +++ b/.changeset/shiny-shirts-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Implement `UrlReader.search` which implements glob matching. diff --git a/.changeset/shy-maps-guess.md b/.changeset/shy-maps-guess.md new file mode 100644 index 0000000000..2a50d3d461 --- /dev/null +++ b/.changeset/shy-maps-guess.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +Migrate to new composability API, exporting the plugin instance as `kubernetesPlugin` and entity content as `EntityKubernetesContent`. diff --git a/.changeset/six-experts-destroy.md b/.changeset/six-experts-destroy.md deleted file mode 100644 index 73347938d9..0000000000 --- a/.changeset/six-experts-destroy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Add config schema for Bitbucket scaffolder diff --git a/.changeset/sixty-lemons-agree.md b/.changeset/sixty-lemons-agree.md new file mode 100644 index 0000000000..7fc438b433 --- /dev/null +++ b/.changeset/sixty-lemons-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kafka': patch +--- + +Migrate to new composability API, exporting the plugin instance as `kafkaPlugin`, entity content as `EntityKafkaContent`, and entity conditional as `isKafkaAvailable`. diff --git a/.changeset/strange-cobras-unite.md b/.changeset/strange-cobras-unite.md new file mode 100644 index 0000000000..d53a324dab --- /dev/null +++ b/.changeset/strange-cobras-unite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Refactored route response handling to use more explicit types and throw errors. diff --git a/.changeset/swift-ears-fetch.md b/.changeset/swift-ears-fetch.md deleted file mode 100644 index 64067cc4f5..0000000000 --- a/.changeset/swift-ears-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-pagerduty': patch ---- - -Optimize empty state image size. diff --git a/.changeset/techdocs-green-rabbits-burn.md b/.changeset/techdocs-green-rabbits-burn.md new file mode 100644 index 0000000000..5af68c2000 --- /dev/null +++ b/.changeset/techdocs-green-rabbits-burn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Enhance API calls to support trapping 500 errors from techdocs-backend diff --git a/.changeset/tough-jars-share.md b/.changeset/tough-jars-share.md deleted file mode 100644 index f1b551f31e..0000000000 --- a/.changeset/tough-jars-share.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-tech-radar': patch ---- - -Updated example data in `README`. diff --git a/.changeset/tricky-dancers-rush.md b/.changeset/tricky-dancers-rush.md new file mode 100644 index 0000000000..301b23d98e --- /dev/null +++ b/.changeset/tricky-dancers-rush.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Fixed type inference of `createRouteRef`. diff --git a/.changeset/two-dogs-search.md b/.changeset/two-dogs-search.md new file mode 100644 index 0000000000..5f673c0cfb --- /dev/null +++ b/.changeset/two-dogs-search.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-gitops-profiles': patch +--- + +Migrated to new composability API, exporting the plugin instance as `gitopsProfilesPlugin` and pages as `GitopsProfilesClusterListPage`, `GitopsProfilesClusterPage`, and `GitopsProfilesCreatePage`. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 495f1ff78d..9a5ef09554 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,7 @@ * @backstage/maintainers /docs/features/techdocs @backstage/techdocs-core +/docs/features/search @backstage/techdocs-core /plugins/cost-insights @backstage/silver-lining /plugins/cloudbuild @trivago/ebarrios /plugins/search @backstage/techdocs-core @@ -13,3 +14,4 @@ /plugins/techdocs-backend @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining +/.changeset/techdocs-* @backstage/techdocs-core diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index f9d5024af4..2991a6babd 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -8,6 +8,8 @@ apis args asciidoc async +Autoscaling +autoscaling Avro backrub Balachandran @@ -65,9 +67,12 @@ Dominik dtuite dzolotusky Ek +etag env Env +esbuild eslint +Expedia facto failover Figma @@ -75,21 +80,23 @@ Firekube Fiverr freben Fredrik -github +Georgoulas +gitbeaker GitHub -gitlab GitLab Grafana +GraphQL graphql graphviz Gustavsson Hackathons haproxy Henneke -heroku Heroku horizontalpodautoscalers Hostname +hotspots +html http https Iain @@ -98,8 +105,8 @@ incentivised inlined inlinehilite interop -javascript -Javascript +Ioannis +JavaScript jq js json @@ -108,9 +115,11 @@ Kaewkasi Knex kubectl kubernetes +Kumar learnings lerna Lerna +Luxon magiclink mailto maintainership @@ -120,6 +129,8 @@ microsite middleware minikube Minikube +misconfiguration +misconfigured misgendering mkdocs Mkdocs @@ -138,11 +149,12 @@ Niklas nodegit nohoist nonces +noop npm nvarchar nvm -oauth OAuth +octokit oidc Okta Oldsberg @@ -182,6 +194,8 @@ rollbar Rollbar Rollup Rosaceae +routable +Routable rst rsync rugvip @@ -194,6 +208,7 @@ semlas semver Serverless Sinon +Sneha Snyk sourcemaps sparklines @@ -203,6 +218,7 @@ Spotify sqlite squidfunk src +stdout stefanalund subkey subtree @@ -232,6 +248,7 @@ transpiled transpilation Tuite ui +unmanaged untracked upvote url @@ -242,6 +259,7 @@ Voi Wealthsimple Weaveworks Webpack +winston www WWW xyz diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35e2a88ff..aa2feebb3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} + INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} + INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} + INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }} steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 62be0a6cea..0fe1642394 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -3,6 +3,7 @@ name: E2E Test Linux on: pull_request: paths-ignore: + - '.changeset/**' - 'contrib/**' - 'docs/**' - 'microsite/**' diff --git a/.github/workflows/fossa.yml b/.github/workflows/fossa.yml new file mode 100644 index 0000000000..f3fe2bbc5f --- /dev/null +++ b/.github/workflows/fossa.yml @@ -0,0 +1,44 @@ +name: FOSSA +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2.3.4 + + # We use this to modify the generated .fossa.yml + - name: Install yq + run: sudo snap install yq + + - name: Install Fossa + run: "curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash" + + - name: Configure Fossa + # The --option flag for fossa init does not work yet, see https://github.com/fossas/fossa-cli/issues/614 + run: | + fossa init + yq eval -i '.analyze.modules[].options.strategy = "yarn-list"' .fossa.yml + + # This deletes entries for template and example packages found within packages and plugins + # Seems like yq has a bug that causes only a subset of all matches to be deleted each run + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + yq eval -i 'del(.analyze.modules[] | select(.path == "*/*/**"))' .fossa.yml + + - name: Show config + run: cat .fossa.yml + + - name: Fossa Analyze + env: + # FOSSA Push-Only API Token + FOSSA_API_KEY: 9ee7e8893660832a7387dcc32377fb61 + run: fossa analyze --branch "$GITHUB_REF" diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index ca402c48d5..a9b5bfdf0e 100644 --- a/.github/workflows/master-win.yml +++ b/.github/workflows/master-win.yml @@ -1,6 +1,7 @@ name: Master Build Windows on: + workflow_dispatch: push: branches: [master] @@ -15,6 +16,10 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} + INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} + INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} + INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }} steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index e9f5e602a5..425e1172a4 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -1,6 +1,7 @@ name: Main Master Build on: + workflow_dispatch: push: branches: [master] @@ -8,6 +9,9 @@ jobs: build: runs-on: ubuntu-latest + outputs: + needs_release: ${{ steps.release_check.outputs.needs_release }} + strategy: matrix: node-version: [12.x, 14.x] @@ -15,6 +19,10 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} + INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} + INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} + INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }} steps: - uses: actions/checkout@v2 @@ -47,6 +55,15 @@ jobs: run: yarn install --frozen-lockfile # End of yarn setup + - name: Fetch previous commit for release check + run: git fetch origin '${{ github.event.before }}' + + - name: Check if release + id: release_check + run: node scripts/check-if-release.js + env: + COMMIT_SHA_BEFORE: '${{ github.event.before }}' + - name: validate config run: yarn backstage-cli config:check @@ -82,9 +99,10 @@ jobs: # We can't re-use the output from the above step, but we'll have a guaranteed node_modules cache and # only run the build steps that are necessary for publishing release: - if: ${{ endsWith(github.event.head_commit.message, 'from backstage/changeset-release/master\n\nVersion Packages') }} needs: build + if: needs.build.outputs.needs_release == 'true' + runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/microsite-build-check.yml b/.github/workflows/microsite-build-check.yml index 8a2fa99ed2..45182229c9 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, 14.x] + node-version: [14.x] env: CI: true @@ -27,6 +27,9 @@ jobs: with: node-version: ${{ matrix.node-version }} + - name: verify doc links + run: node scripts/verify-links.js + # Skip caching of microsite dependencies, it keeps the global cache size # smaller, which make Windows builds a lot faster for the rest of the project. - name: yarn install diff --git a/.github/workflows/microsite-with-storybook-deploy.yml b/.github/workflows/microsite-with-storybook-deploy.yml index 22fd48789a..cf571ef4e8 100644 --- a/.github/workflows/microsite-with-storybook-deploy.yml +++ b/.github/workflows/microsite-with-storybook-deploy.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - node-version: [12.x] + node-version: [14.x] env: CI: true @@ -55,7 +55,7 @@ jobs: run: ls microsite/build/backstage && ls microsite/build/backstage/storybook - name: Deploy both microsite and storybook to gh-pages - uses: JamesIves/github-pages-deploy-action@3.4.2 + uses: JamesIves/github-pages-deploy-action@3.7.1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0dc40d2c40..846882d853 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -55,6 +55,7 @@ jobs: - name: tsc run: yarn tsc + - name: build run: yarn build diff --git a/.github/workflows/techdocs-project-board.yml b/.github/workflows/techdocs-project-board.yml index 679abe6536..99daba61da 100644 --- a/.github/workflows/techdocs-project-board.yml +++ b/.github/workflows/techdocs-project-board.yml @@ -9,17 +9,17 @@ on: pull_request: types: [opened, reopened, labeled, edited] -env: - MY_GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} - jobs: assign_issue_or_pr_to_project: runs-on: ubuntu-latest name: Triage + env: + MY_GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} steps: - name: Assign new issue to Incoming based on its title. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.issue.title, 'TechDocs') || contains(github.event.issue.title, 'techdocs') || contains(github.event.issue.title, 'Techdocs') @@ -30,6 +30,7 @@ jobs: - name: Assign new issue to Incoming based on its label. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.issue.labels.*.name, 'docs-like-code') with: project: 'https://github.com/orgs/backstage/projects/1' @@ -38,6 +39,7 @@ jobs: - name: Assign new PR to Incoming based on its title. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.pull_request.title, 'TechDocs') || contains(github.event.pull_request.title, 'techdocs') || contains(github.event.pull_request.title, 'Techdocs') @@ -48,6 +50,7 @@ jobs: - name: Assign new PR to Incoming based on its label. uses: srggrs/assign-one-project-github-action@1.2.0 if: | + env.MY_GITHUB_TOKEN != null && contains(github.event.pull_request.labels.*.name, 'docs-like-code') with: project: 'https://github.com/orgs/backstage/projects/1' diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml new file mode 100644 index 0000000000..fdb2e1ddc4 --- /dev/null +++ b/.github/workflows/tugboat.yml @@ -0,0 +1,110 @@ +name: Tugboat E2E Tests +on: deployment_status +jobs: + set-pending: + if: github.event.deployment_status.state != 'success' && github.event.deployment_status.state != 'failed' + name: Set pending waiting for Tugboat + runs-on: ubuntu-latest + steps: + # Set an initial commit status message to indicate that the tests are + # running. + - name: set pending status + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + debug: true + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: 'pending', + context: 'Backstage Tugboat E2E Tests', + description: 'Waiting for Tugboat to complete deployment', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); + + run-tests: + # Only run after a successful Tugboat deployment. + if: github.event.deployment_status.state == 'success' + name: Run tests against Tugboat deployment + runs-on: ubuntu-latest + steps: + # Set an initial commit status message to indicate that the tests are + # running. + - name: set pending status + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + debug: true + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: 'pending', + context: 'Backstage Tugboat E2E Tests', + description: 'Running against tugboat preview', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); + + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: '14' + + # This is required because the environment_url param that Tugboat uses + # to tell us where the preview is located isn't supported unless you + # specify the custom Accept header when getting the deployment_status, + # and GitHub actions doesn't do that by default. So instead we have to + # load the status object manually and get the data we need. + # https://developer.github.com/changes/2016-04-06-deployment-and-deployment-status-enhancements/ + - name: get deployment status + id: get-status-env + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + result-encoding: string + script: | + const result = await github.repos.getDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: context.payload.deployment.id, + status_id: context.payload.deployment_status.id, + headers: { + 'Accept': 'application/vnd.github.ant-man-preview+json' + }, + }); + console.log(result); + return result.data.environment_url; + - name: echo tugboat preview url + run: | + curl ${{steps.get-status-env.outputs.result}} + - name: set status + if: ${{ failure() }} + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: "error", + context: 'Backstage Tugboat E2E Tests', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); + - name: set status + if: ${{ success() }} + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: "success", + context: 'Backstage Tugboat E2E Tests', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); diff --git a/.gitignore b/.gitignore index 3334bf956d..57ad74c5cc 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ site # Local configuration files *.local.yaml + +# Sensitive credentials +*-credentials.yaml diff --git a/.tugboat/config.yml b/.tugboat/config.yml new file mode 100644 index 0000000000..b8c3c127c8 --- /dev/null +++ b/.tugboat/config.yml @@ -0,0 +1,17 @@ +services: + backstage: + image: tugboatqa/node:lts + expose: 7000 + default: true + commands: + init: + - mkdir -p /etc/service/node + - echo "#!/bin/sh" > /etc/service/node/run + - echo "yarn --cwd ${TUGBOAT_ROOT} start-backend --config ${TUGBOAT_ROOT}/app-config.yaml --config ${TUGBOAT_ROOT}/.tugboat/tugboat.app-config.production.yaml" >> /etc/service/node/run + - chmod +x /etc/service/node/run + build: + - yarn install --frozen-lockfile + - yarn workspace example-app build + start: + # wget the endpoint. Will retry every 2 seconds. 30 retries = 1m for service to come up. Plenty. + - wget -O /dev/null -o /dev/null --tries=30 --timeout=5 --retry-connrefused http://localhost:7000 diff --git a/.tugboat/tugboat.app-config.production.yaml b/.tugboat/tugboat.app-config.production.yaml new file mode 100644 index 0000000000..f606574d89 --- /dev/null +++ b/.tugboat/tugboat.app-config.production.yaml @@ -0,0 +1,13 @@ +app: + title: Backstage Tugboat Preview + baseUrl: + $env: TUGBOAT_DEFAULT_SERVICE_URL + +backend: + baseUrl: + $env: TUGBOAT_DEFAULT_SERVICE_URL + cors: + origin: + $env: TUGBOAT_DEFAULT_SERVICE_URL + methods: [GET, POST, PUT, DELETE] + credentials: true diff --git a/ADOPTERS.md b/ADOPTERS.md index 493bf499af..363c092198 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,19 +1,21 @@ -| Organization | Contact | Description of Use | -| -------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | -| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | -| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | -| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | -| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | -| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | -| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | +| Organization | Contact | Description of Use | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | +| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | +| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | +| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | +| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo) | EG Common Developer Toolkit | +| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | diff --git a/README.md b/README.md index 141caef7b7..7c04672998 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,6 @@ [![Codecov](https://img.shields.io/codecov/c/github/backstage/backstage)](https://codecov.io/gh/backstage/backstage) [![](https://img.shields.io/npm/v/@backstage/core?label=Version)](https://github.com/backstage/backstage/releases) -> We recently moved from `spotify/backstage`, update your remotes! -> -> `git remote set-url origin git@github.com:backstage/backstage.git` - ## What is Backstage? [Backstage](https://backstage.io/) is an open platform for building developer portals. Powered by a centralized service catalog, Backstage restores order to your microservices and infrastructure and enables your product teams to ship high-quality code quickly — without compromising autonomy. @@ -43,7 +39,7 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how - [Main documentation](https://backstage.io/docs) - [Service Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview) -- [Architecture](https://backstage.io/docs/overview/architecture-terminology) ([Decisions](https://backstage.io/docs/architecture-decisions/adrs-overview)) +- [Architecture](https://backstage.io/docs/overview/architecture-overview) ([Decisions](https://backstage.io/docs/architecture-decisions/adrs-overview)) - [Designing for Backstage](https://backstage.io/docs/dls/design) - [Storybook - UI components](https://backstage.io/storybook) @@ -61,6 +57,6 @@ Check out [the documentation](https://backstage.io/docs/getting-started) on how ## License -Copyright 2020 © Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage +Copyright 2020-2021 © Backstage Project Authors. All rights reserved. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page: https://www.linuxfoundation.org/trademark-usage Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 diff --git a/app-config.yaml b/app-config.yaml index 48869cc0a0..5240a050a8 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -16,6 +16,10 @@ backend: credentials: true csp: connect-src: ["'self'", 'http:', 'https:'] + reading: + allow: + - host: example.com + - host: '*.mozilla.org' # workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir # See README.md in the proxy-backend plugin for information on the configuration format @@ -70,13 +74,11 @@ organization: # Reference documentation http://backstage.io/docs/features/techdocs/configuration techdocs: - requestUrl: http://localhost:7000/api/techdocs - storageUrl: http://localhost:7000/api/techdocs/static/docs builder: 'local' # Alternatives - 'external' generators: techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. + type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage'. Read documentation for using alternatives. sentry: organization: my-company @@ -95,6 +97,13 @@ kubernetes: - 'config' clusters: [] +kafka: + clientId: backstage + clusters: + - name: cluster + brokers: + - localhost:9092 + integrations: github: - host: github.com @@ -127,7 +136,16 @@ integrations: catalog: rules: - - allow: [Component, API, Group, User, Template, Location] + - allow: + - Component + - API + - Resource + - Group + - User + - Template + - System + - Domain + - Location processors: githubOrg: @@ -172,24 +190,46 @@ catalog: # groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') locations: + # Add a location here to ingest it, for example from an URL: + # + # - type: url + # target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-components.yaml + # + # For local development you can use a file location instead: + # + # - type: file + # target: ../catalog-model/examples/all-components.yaml + # + # File locations are relative to the current working directory of the + # backend, for example packages/backend/. + # Backstage example components - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-components.yaml - # Example component for github-actions - - type: url - target: https://github.com/backstage/backstage/blob/master/plugins/github-actions/examples/sample.yaml - # Example component for techdocs - - type: url - target: https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component/documented-component.yaml + - type: file + target: ../catalog-model/examples/all-components.yaml + # Example component for github-actions and TechDocs + - type: file + target: ../../plugins/github-actions/examples/sample.yaml + # Example component for TechDocs + - type: file + target: ../../plugins/techdocs-backend/examples/documented-component/catalog-info.yaml # Backstage example APIs - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml + - type: file + target: ../catalog-model/examples/all-apis.yaml + # Backstage example resources + - type: file + target: ../catalog-model/examples/all-resources.yaml + # Backstage example systems + - type: file + target: ../catalog-model/examples/all-systems.yaml + # Backstage example domains + - type: file + target: ../catalog-model/examples/all-domains.yaml # Backstage example templates - - type: url - target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/all-templates.yaml + - type: file + target: ../../plugins/scaffolder-backend/sample-templates/all-templates.yaml # Backstage example groups and users - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme-corp.yaml + - type: file + target: ../catalog-model/examples/acme-corp.yaml scaffolder: github: @@ -201,6 +241,7 @@ scaffolder: baseUrl: https://gitlab.com token: $env: GITLAB_TOKEN + visibility: public # or 'internal' or 'private' azure: baseUrl: https://dev.azure.com/{your-organization} api: @@ -213,6 +254,8 @@ scaffolder: $env: BITBUCKET_USERNAME token: $env: BITBUCKET_TOKEN + visibility: public # or or 'private' + auth: environment: development ### Providing an auth.session.secret will enable session support in the auth-backend diff --git a/catalog-info.yaml b/catalog-info.yaml index 617d01093e..7e60af5755 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -6,7 +6,7 @@ metadata: Backstage is an open-source developer portal that puts the developer experience first. annotations: github.com/project-slug: backstage/backstage - backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + backstage.io/techdocs-ref: url:https://github.com/backstage/backstage lighthouse.com/website-url: https://backstage.io spec: type: library diff --git a/Dockerfile b/contrib/docker/frontend-with-nginx/Dockerfile similarity index 90% rename from Dockerfile rename to contrib/docker/frontend-with-nginx/Dockerfile index 174548a90c..a444c9de83 100644 --- a/Dockerfile +++ b/contrib/docker/frontend-with-nginx/Dockerfile @@ -8,8 +8,6 @@ FROM nginx:mainline # This dockerfile requires the app to be built on the host first, as it # simply copies in the build output into the image. -# The safest way to build this image is to use `yarn docker-build:app` - RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/* COPY packages/app/dist /usr/share/nginx/html diff --git a/docker/default.conf.template b/contrib/docker/frontend-with-nginx/docker/default.conf.template similarity index 100% rename from docker/default.conf.template rename to contrib/docker/frontend-with-nginx/docker/default.conf.template diff --git a/docker/run.sh b/contrib/docker/frontend-with-nginx/docker/run.sh similarity index 100% rename from docker/run.sh rename to contrib/docker/frontend-with-nginx/docker/run.sh diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md new file mode 100644 index 0000000000..2b8407abd2 --- /dev/null +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -0,0 +1,52 @@ +# Authenticate API requests + +The Backstage backend APIs are by default available without authentication. To avoid evil-doers from accessing or modifying data, one might use a network protection mechanism such as a firewall or an authenticating reverse proxy. For Backstage instances that are available on the Internet one can instead use the experimental IdentityClient as outlined below. + +API requests from frontend plugins include an authorization header with a Backstage identity token acquired when the user logs in. By adding a middleware that verifies said token to be valid and signed by Backstage, non-authenticated requests can be blocked with a 401 Unauthorized response. + +Note that this means Backstage will stop working for guests, as no token is issued for them. + +Caveat: as of writing this, Backstage does not refresh the identity token so eventually users will get a 401 response on API calls (not on loading the web page as only the API calls are authenticated) and have to logout/login again to get a new token. + +```typescript +// packages/backend/src/index.ts from a create-app deployment + +import { Request, Response, NextFunction } from 'express'; +import { IdentityClient } from '@backstage/plugin-auth-backend'; + +// ... + +async function main() { + // ... + + const discovery = SingleHostDiscovery.fromConfig(config); + const identity = new IdentityClient({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }); + const authMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const token = IdentityClient.getBearerToken(req.headers.authorization); + req.user = await identity.authenticate(token); + next(); + } catch (error) { + res.status(401).send(`Unauthorized`); + } + }; + + const apiRouter = Router(); + // The auth route must be publically available as it is used during login + apiRouter.use('/auth', await auth(authEnv)); + // Only authenticated requests are allowed to the routes below + apiRouter.use('/catalog', authMiddleware, await catalog(catalogEnv)); + apiRouter.use('/techdocs', authMiddleware, await techdocs(techdocsEnv)); + apiRouter.use('/proxy', authMiddleware, await proxy(proxyEnv)); + apiRouter.use(authMiddleware, notFoundHandler()); + + // ... +} +``` diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md index 9b5d77bc7c..77b820d921 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md @@ -14,8 +14,8 @@ import { HeaderLabel, SupportButton, identityApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import ExampleFetchComponent from '../ExampleFetchComponent'; const ExampleComponent = () => { diff --git a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md index 6061b69e93..6992d05866 100644 --- a/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md +++ b/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md @@ -11,8 +11,8 @@ import { TableColumn, Progress, githubAuthApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import { graphql } from '@octokit/graphql'; const query = `{ diff --git a/docs/architecture-decisions/adr000-template.md b/docs/architecture-decisions/adr000-template.md new file mode 100644 index 0000000000..5e6edd8dba --- /dev/null +++ b/docs/architecture-decisions/adr000-template.md @@ -0,0 +1,22 @@ +--- +id: adrs-adr000 +title: ADR000: [TITLE] +description: Architecture Decision Record (ADR) for [TITLE] [DESCRIPTION] +--- + + + +## Context + + + +## Decision + + + +## Consequences + + + + diff --git a/docs/architecture-decisions/adr001-add-adr-log.md b/docs/architecture-decisions/adr001-add-adr-log.md index 16783367ab..872b3311a5 100644 --- a/docs/architecture-decisions/adr001-add-adr-log.md +++ b/docs/architecture-decisions/adr001-add-adr-log.md @@ -4,12 +4,16 @@ title: ADR001: Architecture Decision Record (ADR) log description: Architecture Decision Record (ADR) logs as a reference point for the team --- -| Created | Status | -| ---------- | ------ | -| 2020-04-26 | Open | +## Decision -## Decision: A decision was made to store ADRs in a log in the project repository +A decision was made to store ADRs in a log in the project repository -## Discussion: There is a need to store big decisions made in a log as a reference point for the team, help with onboarding new members and give context to others interested in the project. +## Discussion -## Risks: People stop adding ADRs to the log and context gets lost +There is a need to store big decisions made in a log as a reference point for +the team, help with onboarding new members and give context to others interested +in the project. + +## Risks + +People stop adding ADRs to the log and context gets lost diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index 699f7af8ed..8523b4a111 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -4,10 +4,6 @@ title: ADR002: Default Software Catalog File Format description: Architecture Decision Record (ADR) log on Default Software Catalog File Format --- -| Created | Status | -| ---------- | ------ | -| 2020-05-17 | Open | - ## Background Backstage comes with a software catalog functionality, that you can use to track diff --git a/docs/architecture-decisions/adr003-avoid-default-exports.md b/docs/architecture-decisions/adr003-avoid-default-exports.md index d08bc6a882..878d0d701c 100644 --- a/docs/architecture-decisions/adr003-avoid-default-exports.md +++ b/docs/architecture-decisions/adr003-avoid-default-exports.md @@ -4,10 +4,6 @@ title: ADR003: Avoid Default Exports and Prefer Named Exports description: Architecture Decision Record (ADR) log on Avoid Default Exports and Prefer Named Exports --- -| Created | Status | -| ---------- | ------ | -| 2020-05-19 | Open | - ## Context When CommonJS was the primary authoring format, the best practice was to export diff --git a/docs/architecture-decisions/adr004-module-export-structure.md b/docs/architecture-decisions/adr004-module-export-structure.md index 14c94c4ed1..846ca8feae 100644 --- a/docs/architecture-decisions/adr004-module-export-structure.md +++ b/docs/architecture-decisions/adr004-module-export-structure.md @@ -4,10 +4,6 @@ title: ADR004: Module Export Structure description: Architecture Decision Record (ADR) log on Module Export Structure --- -| Created | Status | -| ---------- | ------ | -| 2020-05-27 | Open | - ## Context With a growing number of exports of packages like `@backstage/core`, it is diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md index 34d6449c6b..83196c12e2 100644 --- a/docs/architecture-decisions/adr005-catalog-core-entities.md +++ b/docs/architecture-decisions/adr005-catalog-core-entities.md @@ -4,10 +4,6 @@ title: ADR005: Catalog Core Entities description: Architecture Decision Record (ADR) log on Catalog Core Entities --- -| Created | Status | -| ---------- | ------ | -| 2020-05-29 | Open | - ## Context We want to standardize on a few core entities that we are tracking in the diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md index 51dcf042da..eecb4ad87a 100644 --- a/docs/architecture-decisions/adr006-avoid-react-fc.md +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -43,6 +43,15 @@ const GoodComponent = ({ text, children }: GoodProps) => ( {children} ); + +/* Or as a shorthand, if no specifc child type is required */ +type GoodProps = PropsWithChildren<{ text: string }>; +const GoodComponent = ({ text, children }: GoodProps) => ( +
+
{text}
+ {children} +
+); ``` ## Consequences diff --git a/docs/architecture-decisions/adr010-luxon-date-library.md b/docs/architecture-decisions/adr010-luxon-date-library.md new file mode 100644 index 0000000000..43f18f0f69 --- /dev/null +++ b/docs/architecture-decisions/adr010-luxon-date-library.md @@ -0,0 +1,36 @@ +--- +id: adrs-adr010 +title: ADR010: Use the Luxon Date Library +description: Architecture Decision Record (ADR) for Luxon Date Library +--- + +## Context + +Date formatting (e.g. `a day ago`) and calculations are common within Backstage. +Some of these useful features are not supported by the standard JavaScript +`Date` object. The popular [Moment.js](https://momentjs.com/) library has been +commonly used to fill this gap but suffers from large bundle sizes and mutable +state issues. On top of this, `momentjs` is +[being sunset](https://momentjs.com/docs/#/-project-status/) and the project +recommends using one of the more modern alternative libraries. + +See +[[RFC] Standardized Date & Time Library](https://github.com/backstage/backstage/issues/3401). + +## Decision + +We will use [Luxon](https://moment.github.io/luxon/index.html) as the standard +date library within Backstage. + +`Luxon` provides a similar feature set and API to `Moment.js`, but improves on +its design through immutability and the usage of modern JavaScript APIs (e.g. +`Intl`). This results in smaller bundle sizes while providing a full feature set +and avoids the need for using additional libraries for common date & time tasks. + +## Consequences + +- All core packages and plugins within Backstage should use `Luxon` for any date + manipulation or formatting that cannot be easily accomplished with the native + JavaScript `Date` object. +- Using a single date library avoids having to learn multiple library APIs +- Having a single date library will reduce bundle sizes diff --git a/docs/architecture-decisions/adr011-plugin-package-structure.md b/docs/architecture-decisions/adr011-plugin-package-structure.md new file mode 100644 index 0000000000..9133e5b7c2 --- /dev/null +++ b/docs/architecture-decisions/adr011-plugin-package-structure.md @@ -0,0 +1,74 @@ +--- +id: adrs-adr011 +title: ADR011: Plugin Package Structure +description: Architecture Decision Record (ADR) for Plugin Package Structure +--- + +## Context + +A core feature of Backstage is the extensibility via plugins. The Backstage +repository is open for contributions of plugins. Even most of the core features +are implemented as plugins. A plugin consists of one or multiple packages in the +`plugins/` directory. Up till now, we have a simple conventions for naming +plugin packages: Plugins are named `x`, with the option of having a related +backend plugin called `x-backend` (where `x` is the plugin name, like `catalog` +or `techdocs`). There is a need for sharing code between the frontend and +backend of a plugin, between backend plugins, or components and hooks between +different frontend plugins +([some examples](https://github.com/backstage/backstage/issues/3655#issuecomment-758166746)). +This results in emerging plugin packages with shared code, like +`packages/catalog-client` or `packages/techdocs-common`. + +> There is a common phrase in software development: +> [Naming things is hard](https://martinfowler.com/bliki/TwoHardThings.html) + +To keep the contributed plugins consistent, this Architecture Decision Record +provides rules for naming plugin packages. + +## Decision + +We will place all plugin related code in the `plugins/` directory. The +`packages/` directory is reserved for core package of Backstage. + +We follow this structure for plugin packages (where `x` is the plugin name, for +example `catalog` or `techdocs`): + +- `x`: Contains the main frontend code of the plugin. +- `x-backend`: Contains the main backend code of the plugin. +- `x-react`: Contains shared widgets, hooks and similar that both the plugin + itself (`x`) and third-party frontend plugins can depend on. +- `x-node`: Contains utilities for backends that both the plugin backend itself + (`x-backend`) and third-party backend plugins can depend on. +- `x-common`: An isomorphic package with platform agnostic models, clients, and + utilities that all packages above or any third-party plugin package can depend + on. + +We prefix the package names with `@backstage/plugin-`. + +This structure is based on a +[suggestion in issue #3655](https://github.com/backstage/backstage/issues/3655#issuecomment-758166746). + +## Consequences + +We will actively migrate existing packages that are part of a plugin to the +`plugins/` folder. This affects packages like: + +- `packages/techdocs-common` which should be moved to `plugins/techdocs-node` + and named `@backstage/plugin-techdocs-node`. +- `packages/catalog-client` which will be part of a future + `plugins/catalog-common` and named `@backstage/plugin-catalog-common`. +- While the new location of `packages/catalog-model` should be + `plugins/catalog-common` we might want to do an exception here, as it's a very + central package. + +The limited set of rules might not be sufficient in the future. If additional +packages are required, we will revisit this decision and extend the pattern. + +If possible, we will add tools, such as lint rules, to help enforce the package +names and dependencies between them or CLI commands to generate these packages. + +The distinction between core packages and plugins helps us to setup +[CODEOWNERS](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners) +in the repository. We can set the code owners for the `packages/` folder to the +core team and create additional rules (like `plugins/x*`) for plugin +maintainers. diff --git a/docs/architecture-decisions/index.md b/docs/architecture-decisions/index.md index 3211f37550..f852170b1e 100644 --- a/docs/architecture-decisions/index.md +++ b/docs/architecture-decisions/index.md @@ -18,8 +18,9 @@ Records should be stored under the `architecture-decisions` directory. ### Creating an ADR -- Copy `0000-template.md` to `docs/architecture-decisions/0000-my-decision.md` - (my-decision should be descriptive. Do not assign an ADR number.) +- Copy `docs/architecture-decisions/adr000-template.md` to + `docs/architecture-decisions/adr000-my-decision.md` (my-decision should be + descriptive. Do not assign an ADR number.) - Fill in the ADR following the guidelines in the template - Submit a pull request - Address and integrate feedback from the community diff --git a/docs/architecture.drawio b/docs/architecture.drawio new file mode 100644 index 0000000000..dbe2a5f6fa --- /dev/null +++ b/docs/architecture.drawio @@ -0,0 +1 @@ +7VpLc6M4EP41PiaFBLbh6LxmLlubSqZ2Z+amBQVUBYgScmzPrx9hJIMQGJzEhtTOxUaNnp++bnU3mtm3yfYLQ1n0Fw1wPINWsJ3ZdzMIAfQ88VdIdqXEs2EpCBkJZKVK8Ex+YSm0pHRNApxrFTmlMSeZLvRpmmKfazLEGN3o1V5orI+aoRAbgmcfxab0XxLwqJS6cFnJv2ISRmpksJALTpCqLFeSRyigm5rIvp/Zt4xSXj4l21scF+ApXMp2Dx1vDxNjOOVDGvwk7Nv6n4erTfLNgzcr9/HvH5sru+zlFcVrueBVlsn58p0CId+QJEapKN3I+phxvO2cCDgsT/AC0wRzthNVZANXAiIZARayvKnwPdSJathCRwqR3NPw0HW1bPEgV34CCs4gFBhdpwEu+rEEEJuIcPycIb94uxHkF7KIJ2LcOzAUp+4t6QQP6uA5JnZt0J0NOduEzsAtFMBlnXhIxUX/qerWqXxyFv2EsuFFCQVNvbrfcpzmhKb5aMRyjsPo6CgCZ1xqKSbVMHyia46f8MtEEQSuDqEzNoLgsyPYRkLQpsrnw9CA8IHRVKhyIKSP8Tok6buPy7cgBVtsnHVRG2eSqwOOC1ELfqZz05ucZh6HDyygjh8cFz8wPdPWB+C0COga+I3voPRosM5AAMamoNUN4UQRbPjJYGQOgoWBoAGcOGhXRRgvSn6M8pz4OlYByqM9rhVwODBC+iGwiWHpmvl4wJZzxEJ8rMOOgKUG9LwFaCVjOEacvOqLaENfjvBIiVjeYZu9xjZbjf0rlykbwVrCQPWjGtqNhuWyjYb7vT+s8h10WL6fDmKf2e67VLN94UdRuIZzVb7b1t/e7VRpS/h31Yl4rjcTxapVUVCNzs63oXQblW9LnW/eqXRTNs7R+3EvzD7zRJy6R9F/IF42VgKmV1uciCxF8dSh7D8ZLxx2ms7F6baw1aQdt2iFLXuWQ1DGIxpSsXv3lbTTwvYZ2MnYSm9MW3lg1k63ne81lmA+zFoKwqBdrVpWVMiPzLcxzsI6bVq2Vl88lBP4UMsNB+Sga2px+A5j1d3HooBiEqaFNglGYiYEhaUhPopX8kVCgmCvBKKPrOg52YbFZ6/r8ksTLP+LbhXr9x/CgH0t8L1JKfcjKX+JSfb1zFbtqunvt+SMnBaqw7MZtXnr+TDpoLORj3THTumaIdPkIWy4Ke7IcTs0w4xVRibrmzSV2DWV+LLwfb7MEVhMTYvbHeVpYzgxNbZND1mo8QPyOZW9TxDDiemybWbRDeSGRhkqlBgSZczOHAsobvQGA+VZMFqizr2eL4ED3fJ3sdTY8eY8SiMfA5r0OXMiRV3w+rCs7ttj0jdm/ZafI+tnW7MRydtkWTNqGErXnuzhR8WxDTdq2RPHNr0u7wJxrG1eGjIU508cW23GaHEs+N9uVNellJ1uWj5+Y2bFtRt1U7ZUuOq+sX3/Gw== \ No newline at end of file diff --git a/docs/assets/features/kubernetes/backstage-k8s-2-deployments.png b/docs/assets/features/kubernetes/backstage-k8s-2-deployments.png new file mode 100644 index 0000000000..4a9f2b06fa Binary files /dev/null and b/docs/assets/features/kubernetes/backstage-k8s-2-deployments.png differ diff --git a/docs/assets/search/architecture.drawio.svg b/docs/assets/search/architecture.drawio.svg new file mode 100644 index 0000000000..af04ab63e8 --- /dev/null +++ b/docs/assets/search/architecture.drawio.svg @@ -0,0 +1,541 @@ + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + + +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + + + + + +
+
+
+ App Package: <Route path="/search" element={<... />} /> +
+
+
+
+ + App Package: <Route path="/search" element={<... />}... + +
+
+ + + + +
+
+
+ @backstage/plugin-search-backend +
+
+
+
+ + @backstage/plugin-search-backend + +
+
+ + + + +
+
+
+ Other Plugins +
+ (TechDocs, Catalog, Etc) +
+
+
+
+ + Other Plugins... + +
+
+ + + + +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + + + +
+
+
+ @backstage/plugin-search +
+
+
+
+ + @backstage/plugin-search + +
+
+ + + + + + +
+
+
+ Other Backend Plugin (TechDocs, Catalog, Etc) +
+
+
+
+ + Other Backend Plugin (TechDocs, Catalog, Etc) + +
+
+ + + + +
+
+
+ Search Engine (Elastic, Solr, SaaS, etc.) +
+
+
+
+ + Search Engine (Elastic, Solr, SaaS, et... + +
+
+ + + + +
+
+
+ Search Engine Integration Layer +
+
+
+
+ + Search Engine Integration Layer + +
+
+ + + + + + + + + + + + +
+
+
+ + 1 2 3 + +
+
+
+
+ + 1 2 3 + +
+
+ + + + +
+
+
+ + X number of search results + +
+
+
+
+ + X number of sear... + +
+
+ + + + + + + + +
+
+
+ + Components + +
+
+
+
+ + Components + +
+
+ + + + + + +
+
+
+ Pass Search +
+ Term and Filters +
+ and then +
+ Return Results +
+
+
+
+ + Pass Search... + +
+
+ + + + +
+
+
+ + Search API + +
+
+
+
+ + Search API + +
+
+ + + + + + +
+
+
+ + Components + +
+
+
+
+ + Components + +
+
+ + + + + +
+
+
+ + Scheduler + +
+
+
+
+ + Scheduler + +
+
+ + + + +
+
+
+ + Gather Documents + +
+
+
+
+ + Gather D... + +
+
+ + + + + + + +
+
+
+ + Collate Documents +
+ Or Metadata +
+
+
+
+
+ + Collate Docum... + +
+
+ + + + +
+
+
+ + API Endpoint +
+
+
+
+
+
+ + API Endp... + +
+
+ + + + +
+
+
+ + Query Processing + +
+
+
+
+ + Query Processing + +
+
+ + + + +
+
+
+ + Index Processing + +
+
+
+
+ + Index Processi... + +
+
+ + + + +
+
+
+ + Index Processing + +
+
+
+
+ + Index Processi... + +
+
+ + + + +
+
+
+ + Query Processing + +
+
+
+
+ + Query Processing + +
+
+ + + + +
+
+
+ + + Manage Index + +
+ Create, Remove, Replace Documents and Indices +
+
+
+
+
+ + Manage Index... + +
+
+ + + + + + + +
+
+
+ + Compile and Execute Query from Term and Filters + +
+
+
+
+ + Compile and Execut... + +
+
+
+ + + + + Viewer does not support full SVG 1.1 + + + +
diff --git a/docs/assets/software-catalog/software-model-core-entities.drawio.svg b/docs/assets/software-catalog/software-model-core-entities.drawio.svg new file mode 100644 index 0000000000..2260e5502e --- /dev/null +++ b/docs/assets/software-catalog/software-model-core-entities.drawio.svg @@ -0,0 +1,3 @@ + + +
dependsOn
dependsOn
Resource
(e.g. SQL Database, S3 bucket, ...)
Resource...
consumesAPI
consumesAPI
API
(e.g. OpenAPI, gRPC API, Avro, Dataset, ...)
API...
providesAPI
providesAPI
Component
(e.g. backend service, data pipeline ...)
Component...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/assets/software-catalog/software-model-core-entities.png b/docs/assets/software-catalog/software-model-core-entities.png deleted file mode 100644 index 60cb283802..0000000000 Binary files a/docs/assets/software-catalog/software-model-core-entities.png and /dev/null differ diff --git a/docs/assets/software-catalog/software-model-entities.drawio.svg b/docs/assets/software-catalog/software-model-entities.drawio.svg new file mode 100644 index 0000000000..7b8b88f224 --- /dev/null +++ b/docs/assets/software-catalog/software-model-entities.drawio.svg @@ -0,0 +1,3 @@ + + +
Domain
Domain
partOf
partOf
System
System
dependsOn
dependsOn
partOf
partOf
Resource
(e.g. SQL Database, S3 bucket, ...)
Resource...
consumesAPI
consumesAPI
API
(e.g. OpenAPI, gRPC API, Avro, Dataset, ...)
API...
providesAPI
providesAPI
partOf
partOf
Component
(e.g. backend service, data pipeline ...)
Component...
partOf
partOf
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/auth/index.md b/docs/auth/index.md index cf468c7bb7..a8ddf17c6b 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -86,7 +86,8 @@ to a `guest` identity for all users, without any ID token. To enable sign-in, a `SignInPage` needs to be configured, which in turn has to supply a user to the app. The `@backstage/core` package provides a basic sign-in page that allows both the user and the app developer to choose between a couple of different -sign-in methods. +sign-in methods, or to designate a single provider that may also be logged in to +automatically. ## Further Reading diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index 0655175b58..4209b81279 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -1,9 +1,8 @@ --- id: oauth title: OAuth and OpenID Connect -description: This section describes how Backstage allows plugins to request -OAuth Access Tokens and OpenID Connect ID Tokens on behalf of the user, to be -used for auth to various third party APIs +# prettier-ignore +description: This section describes how Backstage allows plugins to request OAuth Access Tokens and OpenID Connect ID Tokens on behalf of the user, to be used for auth to various third party APIs --- This section describes how Backstage allows plugins to request OAuth Access diff --git a/docs/conf/index.md b/docs/conf/index.md index a6f1d1f6f7..ef6faffd8b 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -18,8 +18,8 @@ allowing for customization. Configuration is stored in YAML files where the defaults are `app-config.yaml` and `app-config.local.yaml` for local overrides. Other sets of files can by loaded by passing `--config ` flags. The configuration files themselves -contain plain YAML, but with support for loading in secrets from various sources -using for example `$env` and `$file` keys. +contain plain YAML, but with support for loading in data and 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 7e8402b6f6..057675c98e 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -97,13 +97,13 @@ order: - If no config flags are provided, `app-config.local.yaml` has higher priority than `app-config.yaml`. -## Secrets and Dynamic Data +## Includes and Dynamic Data -Secrets are supported via special data loading keys that are prefixed with `$`, -which in turn provide a number of different ways to read in secrets. To load a -configuration value as a secret, supply an object with one of the special secret -keys, for example `$env` or `$file`. A full list of supported secret keys can be -found below. For example, the following will read the config key +Includes are supported via special data loading keys that are prefixed with `$`, +which in turn provide a number of different ways to read in data. To load in an +external configuration value, supply an object with one of the special include +keys, for example `$env` or `$file`. A full list of supported include keys can +be found below. For example, the following will read the config key `backend.mySecretKey` from the environment variable `MY_SECRET_KEY`: ```yaml @@ -114,28 +114,26 @@ backend: With the above configuration, calling `config.getString('backend.mySecretKey')` will return the value of the environment variable `MY_SECRET_KEY` when the -backend started up. All secrets are loaded at startup, so changing the contents -of secret files or environment variables will not be reflected at runtime. +backend started up. All includes are loaded at startup, so changing the contents +of files or environment variables will not be reflected at runtime. -As hinted at, secrets can be loaded from a bunch of different sources, and can -be extended with more. Below is a list of the currently supported methods for -loading secrets. +Below is a list of the currently supported methods for loading includes. -### Env Secrets +### Env Includes -This reads a secret from an environment variable. For example, the following -config loads the secret from the `MY_SECRET` env var. +This reads a string value from an environment variable. For example, the +following configuration loads the string value from the `MY_SECRET` environment +variable. ```yaml $env: MY_SECRET ``` -### File Secrets +### File Includes -This reads a secret from the entire contents of a file. The file path is -relative to the `app-config.yaml` the defines the secrets. For example, the -following reads the contents of `my-secret.txt` relative to the config file -itself: +This reads a string value from the entire contents of a text file. The file path +is relative to the source config file. For example, the following reads the +contents of `my-secret.txt` relative to the config file itself: ```yaml $file: ./my-secret.txt @@ -143,10 +141,10 @@ $file: ./my-secret.txt ### Including Files -The `$include` keyword can be used to load in JSON data from an external file. -It's able to load and parse data from `.json`, `.yml`, and `.yaml` files. It's -also possible to include a url fragment (`#`) to point to a value at the given -path in the file. +The `$include` keyword can be used to load configuration values from an external +file. It's able to load and parse data from `.json`, `.yml`, and `.yaml` files. +It's also possible to include a url fragment (`#`) to point to a value at the +given path in the file, using a dot-separated list of keys. For example, the following would read `my-secret-key` from `my-secrets.json`: @@ -163,3 +161,19 @@ Example `my-secrets.json` file: } } ``` + +## Environment Variable Substitution + +Configuration files support environment variable substitution via a `${MY_VAR}` +syntax. For example: + +```yaml +app: + baseUrl: https://${HOST} +``` + +Note that all environment variables must be available, or the entire +configuration value will evaluate to `undefined`. + +The substitution syntax can be escaped using `$${...}`, which will be resolved +as `${...}`. diff --git a/docs/dls/figma.md b/docs/dls/figma.md index c5a33d15c2..21a05d2818 100644 --- a/docs/dls/figma.md +++ b/docs/dls/figma.md @@ -1,8 +1,8 @@ --- id: figma title: Figma -description: Documentation on using Figma to build your own plugins for -Backstage +# prettier-ignore +description: Documentation on using Figma to build your own plugins for Backstage --- We have a [Figma component library](https://www.figma.com/@backstage) that you diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md new file mode 100644 index 0000000000..3e138a7a0b --- /dev/null +++ b/docs/features/kubernetes/configuration.md @@ -0,0 +1,130 @@ +--- +id: configuration +title: Configuring Kubernetes integration +sidebar_label: Configuration +# prettier-ignore +description: Configuring the Kubernetes integration for Backstage expose your entity's objects +--- + +Configuring the Backstage Kubernetes integration involves two steps: + +1. Enabling the backend to collect objects from your Kubernetes cluster(s). +2. Surfacing your Kubernetes objects in catalog entities + +## Configuring Kubernetes Clusters + +The following is a full example entry in `app-config.yaml`: + +```yaml +kubernetes: + serviceLocatorMethod: 'multiTenant' + clusterLocatorMethods: + - 'config' + clusters: + - url: http://127.0.0.1:9999 + name: minikube + authProvider: 'serviceAccount' + serviceAccountToken: + $env: K8S_MINIKUBE_TOKEN + - url: http://127.0.0.2:9999 + name: gke-cluster-1 + authProvider: 'google' +``` + +### `serviceLocatorMethod` + +This configures how to determine which clusters a component is running in. + +Currently, the only valid value is: + +- `multiTenant` - This configuration assumes that all components run on all the + provided clusters. + +### `clusterLocatorMethods` + +This is an array used to determine where to retrieve cluster configuration from. + +Currently, the only valid cluster locator method is: + +- `config` - This cluster locator method will read cluster information from your + app-config (see below). + +### `clusters` + +Used by the `config` cluster locator method to construct Kubernetes clients. + +### `clusters.\*.url` + +The base URL to the Kubernetes control plane. Can be found by using the +"Kubernetes master" result from running the `kubectl cluster-info` command. + +### `clusters.\*.name` + +A name to represent this cluster, this must be unique within the `clusters` +array. Users will see this value in the Service Catalog Kubernetes plugin. + +### `clusters.\*.authProvider` + +This determines how the Kubernetes client authenticates with the Kubernetes +cluster. Valid values are: + +| Value | Description | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | +| `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | + +### `clusters.\*.serviceAccount` (optional) + +The service account token to be used when using the `serviceAccount` auth +provider. + +### Role Based Access Control + +The current RBAC permissions required are read-only cluster wide, for the +following objects: + +- pods +- services +- configmaps +- deployments +- replicasets +- horizontalpodautoscalers +- ingresses + +## Surfacing your Kubernetes components as part of an entity + +There are two ways to surface your Kubernetes components as part of an entity. +The label selector takes precedence over the annotation/service id. + +### Common `backstage.io/kubernetes-id` label + +#### Adding the entity annotation + +In order for Backstage to detect that an entity has Kubernetes components, the +following annotation should be added to the entity's `catalog-info.yaml`: + +```yaml +annotations: + 'backstage.io/kubernetes-id': dice-roller +``` + +#### Labeling Kubernetes components + +In order for Kubernetes components to show up in the service catalog as a part +of an entity, Kubernetes components themselves can have the following label: + +```yaml +'backstage.io/kubernetes-id': +``` + +### Label selector query annotation + +You can write your own custom label selector query that Backstage will use to +lookup the objects (similar to `kubectl --selector="your query here"`). Review +the +[labels and selectors Kubernetes documentation](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) +for more info. + +```yaml +'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end' +``` diff --git a/docs/features/kubernetes/index.md b/docs/features/kubernetes/index.md new file mode 100644 index 0000000000..26794eca10 --- /dev/null +++ b/docs/features/kubernetes/index.md @@ -0,0 +1,30 @@ +--- +id: overview +title: Kubernetes +sidebar_label: Overview +description: Monitoring Kubernetes based services with the service catalog +--- + +Kubernetes in Backstage is a tool that's designed around the needs of service +owners, not cluster admins. Now developers can easily check the health of their +services no matter how or where those services are deployed — whether it's on a +local host for testing or in production on dozens of clusters around the world. + +It will elevate the visibility of errors where identified, and provide drill +down about the deployments, pods, and other objects for a service. + +![Kubernetes plugin screenshot](../../assets/features/kubernetes/backstage-k8s-2-deployments.png) + +The feature is made up of two plugins: +[`@backstage/plugin-kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes) +and +[`@backstage/plugin-kubernetes-backend`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend). + +The frontend plugin exposes information to the end user in a digestible way, +while the backend wraps the mechanics to connect to Kubernetes clusters to +collect the relevant information. + +## Let's use it! + +To get started, first you must [install the Kubernetes plugins](installation.md) +and then [configure them](configuration.md). diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md new file mode 100644 index 0000000000..35a027df62 --- /dev/null +++ b/docs/features/kubernetes/installation.md @@ -0,0 +1,119 @@ +--- +id: installation +title: Installation +description: Installing Kubernetes plugin into Backstage +--- + +The Kubernetes feature is a plugin to Backstage, and it is exposed as a tab when +viewing entities in the software catalog. + +If you haven't setup Backstage already, read the +[Getting Started](../../getting-started/index.md). + +## Adding the Kubernetes frontend plugin + +The first step is to add the frontend Kubernetes plugin to your Backstage +application. Navigate to your new Backstage application directory. And then to +your `packages/app` directory, and install the `@backstage/plugin-kubernetes` +package. + +```bash +cd my-backstage-app/ +cd packages/app +yarn add @backstage/plugin-kubernetes +``` + +Once the package has been installed, you need to import the plugin in your app. +Add the following to `packages/app/src/plugins.ts`: + +`plugins.ts`: + +```typescript +export { plugin as Kubernetes } from '@backstage/plugin-kubernetes'; +``` + +Now, add the "Kubernetes" tab to the catalog entity page. In +`packages/app/src/components/catalog/EntityPage.tsx`, you'll add a router to get +to the tab, and add the tab itself. + +`EntityPage.tsx`: + +```tsx +import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; + +// ... + +const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( + + // ... + } + /> + // ... + +); +``` + +That's it! But now, we need the Kubernetes Backend plugin for the frontend to +work. + +## Adding Kubernetes Backend plugin + +Navigate to `packages/backend` of your Backstage app, and install the +`@backstage/plugin-kubernetes-backend` package. + +```bash +cd my-backstage-app/ +cd packages/backend +yarn add @backstage/plugin-kubernetes-backend +``` + +Create a file called `kubernetes.ts` inside `packages/backend/src/plugins/` and +add the following + +`kubernetes.ts`: + +```typescript +import { createRouter } from '@backstage/plugin-kubernetes-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); +} +``` + +And import the plugin to `packages/backend/src/index.ts`. There are three lines +of code you'll need to add, and they should be added near similar code in your +existing Backstage backend. + +`index.ts`: + +```typescript +import kubernetes from './plugins/kubernetes'; + +// ... + +const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); + +// ... + +apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); +``` + +That's it! The Kubernetes frontend and backend have now been added to your +Backstage app. + +## Running Backstage locally + +Start the frontend and the backend app by +[running Backstage locally](../../getting-started/running-backstage-locally.md). + +## Configuration + +After installing the plugins in the code, you'll need to then +[configure them](configuration.md). diff --git a/docs/features/search/README.md b/docs/features/search/README.md new file mode 100644 index 0000000000..a06b9aee2b --- /dev/null +++ b/docs/features/search/README.md @@ -0,0 +1,106 @@ +--- +id: search-overview +title: Search Documentation +sidebar_label: Overview +# prettier-ignore +description: Backstage Search lets you find the right information you are looking for in the Backstage ecosystem. +--- + +# Backstage Search + +## What is it? + +Backstage Search lets you find the right information you are looking for in the +Backstage ecosystem. + +## Features + +- A federated, faceted search, searching across all entities registered in your + Backstage instance. + +- A search that lets you plug in your own search engine of choice. + +- A standardized search API where you can choose to index other plugins data. + +## Project roadmap + +| Version | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Backstage Search v0 ✅ | Search Frontend letting you search through the entities of the software catalog. [See v0 Use Cases.](#backstage-search-v0) | +| [Backstage Search V0.5 ⌛][v0.5] | Foundations for the architecture. | +| [Backstage Search v1 ⌛][v1] | Basic “out-of-the-box” in-memory indexing process of entities, and their metadata, registered to the Software Catalog. [See v1 Use Cases.](#backstage-search-v1) | +| [Backstage Search v2 ⌛][v2] | Search Backend responsible for the indexing process of entities, and their metadata, registered to the Software Catalog. [See v2 Use Cases.](#backstage-search-v2) | +| [Backstage Search v3 ⌛][v3] | Standardized Search API lets you index other plugins data to the search engine of choice. [See v3 Use Cases.](#backstage-search-v3) | + +[v0.5]: https://github.com/backstage/backstage/milestone/25 +[v1]: https://github.com/backstage/backstage/milestone/26 +[v2]: https://github.com/backstage/backstage/milestone/27 +[v3]: https://github.com/backstage/backstage/milestone/28 + +## Use Cases + +#### Backstage Search V.0 + +- As a software engineer I should be able to navigate to a search page and + search for entities registered in the Software Catalog. +- As a software engineer I should be able to use the search input field in the + sidebar to search for entities registered in the Software Catalog. +- As a software engineer I should be able to see the number of results my search + returned. +- As a software engineer I should be able to filter on metadata (kind, + lifecycle) when I’ve performed a search. +- As a software engineer I should be able to hide the filters if I don’t need to + use them. + +#### Backstage Search V.1 + +- As a software engineer I should be able to get a match of a search on all + entity metadata (e.g. owner, name, description, kind). +- As an integrator I should not have to plug in any search engine, instead I can + use the out of the box in-memory indexing process to index entities and their + metadata registered in the Software Catalog. + +#### Backstage Search V.2 + +- As an integrator I should be able to spin up an instance of ElasticSearch. +- As an integrator I should be able to define a ElasticSearch cluster in my + app_config.yaml where my data gets indexed to. + +more to come... + +#### Backstage Search V.3 + +- As a contributor I should be able to integrate plugin data to the indexing + process of Backstage Search by using the standardized API. +- As a software engineer I should be able to search for all content (for + example, entities, metadata, documentation) in Backstage search. + +more to come... + +## Search Engines Supported + +See [Backstage Search Architecture](architecture.md) to get an overview of how +the search engines are used. + +| Search Engine | Support Status | +| ------------- | -------------- | +| ElasticSearch | Not yet ❌ | + +[Reach out to us](#feedback) if you want to chat about support for more search +engines. + +## Tech Stack + +| Stack | Location | +| --------------- | ------------------------ | +| Frontend Plugin | @backstage/plugin-search | +| Backend Plugin | ⌛ | + +## Feedback + +For any questions of feedback, reach out to us in the `#search` channel of our +[Discord chatroom](https://github.com/backstage/backstage#community). + +We are still looking for feedback to improve the architecture to fit your +use-case, see +[this open issue](https://github.com/backstage/backstage/issues/4078). diff --git a/docs/features/search/architecture.md b/docs/features/search/architecture.md new file mode 100644 index 0000000000..3075719b9d --- /dev/null +++ b/docs/features/search/architecture.md @@ -0,0 +1,39 @@ +--- +id: architecture +title: Search Architecture +description: Documentation on Search Architecture +--- + +# Search Architecture + +> _This is a proposed architecture which has not been implemented yet. We are +> still looking for feedback to improve the architecture to fit your use-case, +> see [this open issue](https://github.com/backstage/backstage/issues/4078)._ + +Below you can explore the Search Architecture. Our aim with this architecture is +to support a wide variety of search engines, while providing a simple developer +experience for plugin developers, and a good out-of-the-box experience for +Backstage end-users. + +Search Architecture + +At a base-level, we want to support the following: + +- We aim to enable the capability to search across the entire Backstage + ecosystem by decoupling search from content management. +- We aim to enable the capability to deploy Backstage using any search engine, + by providing an integration and translation layer between the core search + plugin and search engine specific logic that can be extended for different + search engines. We may also introduce the ability to replace the backend API + endpoint with a custom endpoint for simpler customization. + +More advanced use-cases we hope to support with this architecture include: + +- It should be easy for any plugin to expose new content to search. (e.g. entity + metadata, documentation from TechDocs) +- It should be easy for any plugin to append relevant metadata to existing + content in search. (e.g. location (path) for TechDocs page) +- It should be easy to refine search queries (e.g. ranking, scoring, etc.) +- It should be easy to customize the search UI +- It should be easy to add search functionality to any Backstage plugin or + deployment diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md index d10fb29e30..d5ad240dde 100644 --- a/docs/features/software-catalog/configuration.md +++ b/docs/features/software-catalog/configuration.md @@ -60,7 +60,7 @@ data from. Each entry is a structure with up to four elements: and raw. If it is not supplied, anonymous access will be used. - `apiBaseUrl` (optional): If you want to communicate using the APIv3 method with this provider, specify the base URL for its endpoint here, with no - trailing slash. Specifically when the target is github, you can leave it out + trailing slash. Specifically when the target is GitHub, you can leave it out to be inferred automatically. For a GitHub Enterprise installation, it is commonly at `https://api.` or `https:///api/v3`. - `rawBaseUrl` (optional): If you want to communicate using the raw HTTP method diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 763acd2261..1aab1268cd 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -54,13 +54,18 @@ software catalog API. "labels": { "system": "public-websites" }, + "links": [{ + "url": "https://admin.example-org.com", + "title": "Admin Dashboard", + "icon": "dashboard" + }], "tags": ["java"], "name": "artist-web", "uid": "2152f463-549d-4d8d-a94d-ce2b7676c6e2" }, "spec": { "lifecycle": "production", - "owner": "artist-relations@example.com", + "owner": "artist-relations-team", "type": "website" } } @@ -81,10 +86,14 @@ metadata: circleci.com/project-slug: github/example-org/artist-website tags: - java + links: + - url: https://admin.example-org.com + title: Admin Dashboard + icon: dashboard spec: type: website lifecycle: production - owner: artist-relations@example.com + owner: artist-relations-team ``` The root fields `apiVersion`, `kind`, `metadata`, and `spec` are part of the @@ -131,6 +140,19 @@ spec: $text: https://petstore.swagger.io/v2/swagger.json ``` +Note that to be able to read from targets that are outside of the normal +integration points such as `github.com`, you'll need to explicitly allow it by +adding an entry in the `backend.reading.allow` list. For example: + +```yml +backend: + baseUrl: ... + reading: + allow: + - host: example.com + - host: '*.examples.org' +``` + ## Common to All Kinds: The Envelope The root envelope object has the following structure. @@ -268,7 +290,7 @@ identical in use to Their purpose is mainly, but not limited, to reference into external systems. This could for example be a reference to the git ref the entity was ingested -from, to monitoring and logging systems, to pagerduty schedules, etc. Users may +from, to monitoring and logging systems, to PagerDuty schedules, etc. Users may add these to descriptor YAML files, but in addition to this automated systems may also add annotations, either during ingestion into the catalog, or at a later time. @@ -301,6 +323,34 @@ This field is optional, and currently has no special semantics. Each tag must be sequences of `[a-z0-9]` separated by `-`, at most 63 characters in total. +### `links` [optional] + +A list of external hyperlinks related to the entity. Links can provide +additional contextual information that may be located outside of Backstage +itself. For example, an admin dashboard or external CMS page. + +Users may add links to descriptor YAML files to provide additional reference +information to external content & resources. Links are not intended to drive any +additional functionality within Backstage, which is best left to `annotations` +and `labels`. It is recommended to use links only when an equivalent well-known +`annotation` does not cover a similar use case. + +Fields of a link are: + +| Field | Type | Description | +| ------- | ------ | ------------------------------------------------------------------------------------ | +| `url` | String | [Required] A `url` in a standard `uri` format (e.g. `https://example.com/some/page`) | +| `title` | String | [Optional] A user friendly display name for the link. | +| `icon` | String | [Optional] A key representing a visual icon to be displayed in the UI. | + +_NOTE_: The `icon` field value is meant to be a semantic key that will map to a +specific icon that may be provided by an icon library (e.g. `material-ui` +icons). These keys should be a sequence of `[a-z0-9A-Z]`, possibly separated by +one of `[-_.]`. Backstage may support some basic icons out of the box, but the +Backstage integrator will ultimately be left to provide the appropriate icon +component mappings. A generic fallback icon would be provided if a mapping +cannot be resolved. + ## Common to All Kinds: Relations The `relations` root field is a read-only list of relations, between the current @@ -381,7 +431,8 @@ metadata: spec: type: website lifecycle: production - owner: artist-relations@example.com + owner: artist-relations-team + system: artist-engagement-portal providesApis: - artist-api ``` @@ -427,8 +478,8 @@ The current set of well-known and common values for this field is: ### `spec.owner` [required] -The owner of the component, e.g. `artist-relations@example.com`. This field is -required. +An [entity reference](#string-references) to the owner of the component, e.g. +`artist-relations-team`. This field is required. In Backstage, the owner of a component is the singular entity (commonly a team) that bears ultimate responsibility for the component, and has the authority and @@ -440,25 +491,45 @@ not to be used by automated processes to for example assign authorization in runtime systems. There may be others that also develop or otherwise touch the component, but there will always be one ultimate owner. -Apart from being a string, the software catalog leaves the format of this field -open to implementers to choose. Most commonly, it is set to the ID or email of a -group of people in an organizational structure. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + +### `spec.system` [optional] + +An [entity reference](#string-references) to the system that the component +belongs to, e.g. `artist-engagement-portal`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`System`](#kind-system) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | + +### `spec.subcomponentOf` [optional] + +An [entity reference](#string-references) to another component of which the +component is a part, e.g. `spotify-ios-app`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`Component`](#kind-component) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | ### `spec.providesApis` [optional] -Links APIs that are provided by the component, e.g. `artist-api`. This field is -optional. +An array of [entity references](#string-references) to the APIs that are +provided by the component, e.g. `artist-api`. This field is optional. -The software catalog expects a list of one or more strings that references the -names of other entities of the `kind` `API`. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| [`API`](#kind-api) (default) | Same as this entity, typically `default` | [`providesApi`, and reverse `apiProvidedBy`](well-known-relations.md#providesapi-and-apiprovidedby) | ### `spec.consumesApis` [optional] -Links APIs that are consumed by the component, e.g. `artist-api`. This field is -optional. +An array of [entity references](#string-references) to the APIs that are +consumed by the component, e.g. `artist-api`. This field is optional. -The software catalog expects a list of one or more strings that references the -names of other entities of the `kind` `API`. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| [`API`](#kind-api) (default) | Same as this entity, typically `default` | [`consumesApi`, and reverse `apiConsumedBy`](well-known-relations.md#consumesapi-and-apiconsumedby) | ## Kind: Template @@ -597,7 +668,8 @@ metadata: spec: type: openapi lifecycle: production - owner: artist-relations@example.com + owner: artist-relations-team + system: artist-engagement-portal definition: | openapi: "3.0.0" info: @@ -663,8 +735,8 @@ The current set of well-known and common values for this field is: ### `spec.owner` [required] -The owner of the API, e.g. `artist-relations@example.com`. This field is -required. +An [entity reference](#string-references) to the owner of the component, e.g. +`artist-relations-team`. This field is required. In Backstage, the owner of an API is the singular entity (commonly a team) that bears ultimate responsibility for the API, and has the authority and capability @@ -676,9 +748,18 @@ processes to for example assign authorization in runtime systems. There may be others that also develop or otherwise touch the API, but there will always be one ultimate owner. -Apart from being a string, the software catalog leaves the format of this field -open to implementers to choose. Most commonly, it is set to the ID or email of a -group of people in an organizational structure. +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + +### `spec.system` [optional] + +An [entity reference](#string-references) to the system that the API belongs to, +e.g. `artist-engagement-portal`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`System`](#kind-system) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | ### `spec.definition` [required] @@ -751,11 +832,11 @@ 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. +[entity reference](https://backstage.io/docs/features/software-catalog/references). + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default) | Same as this entity, typically `default` | [`childOf`, and reverse `parentOf`](well-known-relations.md#parentof-and-childof) | ### `spec.children` [required] @@ -765,11 +846,11 @@ 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. +[entity references](https://backstage.io/docs/features/software-catalog/references). + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default) | Same as this entity, typically `default` | [`hasMember`, and reverse `memberOf`](well-known-relations.md#memberof-and-hasmember) | ## Kind: User @@ -825,23 +906,200 @@ 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. +[entity references](https://backstage.io/docs/features/software-catalog/references). + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default) | Same as this entity, typically `default` | [`memberOf`, and reverse `hasMember`](well-known-relations.md#memberof-and-hasmember) | ## Kind: Resource -This kind is not yet defined, but is reserved [for future use](system-model.md). +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `Resource` | + +A resource describes the infrastructure a system needs to operate, like BigTable +databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with +components and systems allows to visualize resource footprint, and create +tooling around them. + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Resource +metadata: + name: artists-db + description: Stores artist details +spec: + type: database + owner: artist-relations-team + system: artist-engagement-portal +``` + +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 `Resource`, respectively. + +### `spec.owner` [required] + +An [entity reference](#string-references) to the owner of the resource, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a resource is the singular entity (commonly a team) +that bears ultimate responsibility for the resource, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this resource belongs. It is +not to be used by automated processes to for example assign authorization in +runtime systems. There may be others that also manage or otherwise touch the +resource, but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + +### `spec.type` [required] + +The type of resource as a string, e.g. `database`. This field is required. 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 the resources used +in their tech stack. + +Some common values for this field could be: + +- `database` +- `s3-bucket` +- `cluster` + +### `spec.system` [optional] + +An [entity reference](#string-references) to the system that the resource +belongs to, e.g. `artist-engagement-portal`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`System`](#kind-system) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | ## Kind: System -This kind is not yet defined, but is reserved [for future use](system-model.md). +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `System` | + +A system is a collection of resources and components. The system may expose or +consume one or several APIs. It is viewed as abstraction level that provides +potential consumers insights into exposed features without needing a too +detailed view into the details of all components. This also gives the owning +team the possibility to decide about published artifacts and APIs. + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: artist-engagement-portal + description: Handy tools to keep artists in the loop +spec: + owner: artist-relations-team + domain: artists +``` + +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 `System`, respectively. + +### `spec.owner` [required] + +An [entity reference](#string-references) to the owner of the system, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a system is the singular entity (commonly a team) +that bears ultimate responsibility for the system, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this system belongs. It is not +to be used by automated processes to for example assign authorization in runtime +systems. There may be others that also develop or otherwise touch the system, +but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | + +### `spec.domain` [optional] + +An [entity reference](#string-references) to the domain that the system belongs +to, e.g. `artists`. This field is optional. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| --------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | +| [`Domain`](#kind-domain) (default) | Same as this entity, typically `default` | [`partOf`, and reverse `hasPart`](well-known-relations.md#partof-and-haspart) | ## Kind: Domain -This kind is not yet defined, but is reserved [for future use](system-model.md). +Describes the following entity kind: + +| Field | Value | +| ------------ | ----------------------- | +| `apiVersion` | `backstage.io/v1alpha1` | +| `kind` | `Domain` | + +A Domain groups a collection of systems that share terminology, domain models, +business purpose, or documentation, i.e. form a bounded context. + +Descriptor files for this kind may look as follows. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Domain +metadata: + name: artists + description: Everything about artists +spec: + owner: artist-relations-team +``` + +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 `Domain`, respectively. + +### `spec.owner` [required] + +An [entity reference](#string-references) to the owner of the domain, e.g. +`artist-relations-team`. This field is required. + +In Backstage, the owner of a domain is the singular entity (commonly a team) +that bears ultimate responsibility for the domain, and has the authority and +capability to develop and maintain it. They will be the point of contact if +something goes wrong, or if features are to be requested. The main purpose of +this field is for display purposes in Backstage, so that people looking at +catalog items can get an understanding of to whom this domain belongs. It is not +to be used by automated processes to for example assign authorization in runtime +systems. There may be others that also develop or otherwise touch the domain, +but there will always be one ultimate owner. + +| [`kind`](#apiversion-and-kind-required) | Default [`namespace`](#namespace-optional) | Generated [relation](well-known-relations.md) type | +| ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | +| [`Group`](#kind-group) (default), [`User`](#kind-user) | Same as this entity, typically `default` | [`ownerOf`, and reverse `ownedBy`](well-known-relations.md#ownedby-and-ownerof) | ## Kind: Location diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md index e7d253f11e..e278bc7a1a 100644 --- a/docs/features/software-catalog/external-integrations.md +++ b/docs/features/software-catalog/external-integrations.md @@ -94,7 +94,7 @@ The recommended way of instantiating the catalog backend classes is to use the as illustrated in the [example backend here](https://github.com/backstage/backstage/blob/master/packages/backend/src/plugins/catalog.ts). We will create a new -[`CatalogProcessor`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/ingestion/types.ts) +[`CatalogProcessor`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/ingestion/processors/types.ts) subclass that can be added to this catalog builder. It is up to you where you put the code for this new processor class. For quick diff --git a/docs/features/software-catalog/system-model.md b/docs/features/software-catalog/system-model.md index d797e87499..f0ead20664 100644 --- a/docs/features/software-catalog/system-model.md +++ b/docs/features/software-catalog/system-model.md @@ -23,7 +23,7 @@ We model software in the Backstage catalogue using these three core entities - **Resources** are physical or virtual infrastructure needed to operate a component -![](../../assets/software-catalog/software-model-core-entities.png) +![](../../assets/software-catalog/software-model-core-entities.drawio.svg) ### Component @@ -73,6 +73,8 @@ these entities using the following (optional) concepts: function - **Domains** relate entities and systems to part of the business +![](../../assets/software-catalog/software-model-entities.drawio.svg) + ### System With increasing complexity in software, systems form an important abstraction @@ -107,10 +109,6 @@ product or use-case, share the same entity types in their APIs, and integrate well with each other. Other domains could be “Content Ingestion”, “Ads” or “Search”. -## Current status - -Backstage currently supports Components and APIs. - ## Links - [Original RFC](https://github.com/backstage/backstage/issues/390) diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 3627c74b9a..2cbb829554 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -22,7 +22,7 @@ use. # Example: metadata: annotations: - backstage.io/managed-by-location: github:http://github.com/backstage/backstage/catalog-info.yaml + backstage.io/managed-by-location: url:http://github.com/backstage/backstage/blob/master/catalog-info.yaml ``` The value of this annotation is a so called location reference string, that @@ -30,8 +30,8 @@ points to the source from which the entity was originally fetched. This annotation is added automatically by the catalog as it fetches the data from a registered location, and is not meant to normally be written by humans. The annotation may point to any type of generic location that the catalog supports, -so it cannot be relied on to always be specifically of type `github`, nor that -it even represents a single file. Note also that a single location can be the +so it cannot be relied on to always be specifically of type `url`, nor that it +even represents a single file. Note also that a single location can be the source of many entities, so it represents a many-to-one relationship. The format of the value is `:`. Note that the target may also @@ -40,13 +40,30 @@ 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/managed-by-origin-location + +```yaml +# Example: +metadata: + annotations: + backstage.io/managed-by-origin-location: url:http://github.com/backstage/backstage/blob/master/catalog-info.yaml +``` + +The value of this annotation is a location reference string (see above). It +points to the location, whose registration lead to the creation of the entity. +In most cases, the `backstage.io/managed-by-location` and +`backstage.io/managed-by-origin-location` will be equal. They will be different +if the original location delegates to another location. A common case is, that a +location is registered as `bootstrap:bootstrap` which means that it is part of +the `app-config.yaml` of a Backstage installation. + ### backstage.io/techdocs-ref ```yaml # Example: metadata: annotations: - backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + backstage.io/techdocs-ref: url:https://github.com/backstage/backstage/tree/master ``` The value of this annotation is a location reference string (see above). If this diff --git a/docs/features/software-catalog/well-known-relations.md b/docs/features/software-catalog/well-known-relations.md index 54f7833d1b..6fb7ae2fea 100644 --- a/docs/features/software-catalog/well-known-relations.md +++ b/docs/features/software-catalog/well-known-relations.md @@ -48,11 +48,10 @@ where present. ### `providesApi` and `apiProvidedBy` A relation with an [API](descriptor-format.md#kind-api) entity, typically from a -[Component](descriptor-format.md#kind-component) or -[System](descriptor-format.md#kind-system). +[Component](descriptor-format.md#kind-component). -These relations express that a component or system exposes an API - meaning that -it hosts callable endpoints from which you can consume that API. +These relations express that a component exposes an API - meaning that it hosts +callable endpoints from which you can consume that API. This relation is commonly generated based on `spec.providesApis` of the component or system in question. @@ -60,11 +59,10 @@ component or system in question. ### `consumesApi` and `apiConsumedBy` A relation with an [API](descriptor-format.md#kind-api) entity, typically from a -[Component](descriptor-format.md#kind-component) or -[System](descriptor-format.md#kind-system). +[Component](descriptor-format.md#kind-component). -These relations express that a component or system consumes an API - meaning -that it depends on endpoints of the API. +These relations express that a component consumes an API - meaning that it +depends on endpoints of the API. This relation is commonly generated based on `spec.consumesApis` of the component or system in question. @@ -91,3 +89,18 @@ A membership relation, typically for [Users](descriptor-format.md#kind-user) in [Groups](descriptor-format.md#kind-group). This relation is commonly based on `spec.memberOf`. + +### `partOf` and `hasPart` + +A relation with a [Domain](descriptor-format.md#kind-domain), +[System](descriptor-format.md#kind-system) or +[Component](descriptor-format.md#kind-component) entity, typically from a +[Component](descriptor-format.md#kind-component), +[API](descriptor-format.md#kind-api), or +[System](descriptor-format.md#kind-system). + +These relations express that a component belongs to a larger component; a +component, API or resource belongs to a system; or that a system is grouped +under a domain. + +This relation is commonly based on `spec.system` or `spec.domain`. 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 97854c999a..5e2809e006 100644 --- a/docs/features/software-templates/extending/create-your-own-publisher.md +++ b/docs/features/software-templates/extending/create-your-own-publisher.md @@ -57,7 +57,7 @@ That type looks like the following: export type PublisherBase = { publish(opts: { entity: TemplateEntityV1alpha1; - values: RequiredTemplateValues & Record; + values: TemplaterValues; directory: string; }): Promise<{ remoteUrl: string }>; }; 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 63acd38286..28769d77e1 100644 --- a/docs/features/software-templates/extending/create-your-own-templater.md +++ b/docs/features/software-templates/extending/create-your-own-templater.md @@ -61,7 +61,7 @@ That type looks like the following: ```ts export type TemplaterRunOptions = { directory: string; - values: RequiredTemplateValues & Record; + values: TemplaterValues; logStream?: Writable; dockerClient: Docker; }; @@ -86,10 +86,11 @@ follows: _note_ Currently the templaters that we provide are basically Docker action containers that are run on top of the skeleton folder. This keeps dependencies -to a minimal for running backstage scaffolder, but you don't /have/ to use -Docker. You could create your own templater that spins up an EC2 instance and -downloads the folder and does everything using an AMI if you want. It's entirely -up to you! +to a minimum for running Backstage scaffolder, but you don't _have_ to use +Docker. You can `pip install cookiecutter` to run it locally in your backend. +You could create your own templater that spins up an EC2 instance and downloads +the folder and does everything using an AMI if you want. It's entirely up to +you! Now it's up to you to implement the `run` function, and then return a `TemplaterRunResult` which is `{ resultDir: string }`. diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 3ebb426466..12ce6e3ed3 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -2,8 +2,8 @@ id: software-templates-index title: Backstage Software Templates sidebar_label: Overview -description: The Software Templates part of Backstage is a tool that can help -you create Components inside Backstage +# prettier-ignore +description: The Software Templates part of Backstage is a tool that can help you create Components inside Backstage --- The Software Templates part of Backstage is a tool that can help you create @@ -39,13 +39,12 @@ internally. ![Enter some variables](../../assets/software-templates/template-picked.png) After filling in these variables, you'll get some more fields to fill out which -are required for backstage usage: the owner (which is a `user` in the backstage -system), the `storePath` (which right now must be a GitHub Organisation or -GitHub user and a non-existing GitHub repository name in the format -`organisation/reponame`), and a GitHub team or user account which should be -granted admin access to the repository. +are required for Backstage usage: the owner (which is a `user` in the backstage +system), and the `storePath` which is a destination URL to create for the +provider, for instance `https://github.com/backstage/my-new-repository`, or +`https://gitlab.com/myorg/myrepo`. -![Enter backstage vars](../../assets/software-templates/template-picked-2.png) +![Enter Backstage vars](../../assets/software-templates/template-picked-2.png) ### Run! diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 724b86aed1..8d2fa7727d 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -173,7 +173,12 @@ and access to a running Docker daemon. You can create a GitHub access token docs on creating private GitHub access tokens is available [here](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). Note that the need for private GitHub access tokens will be replaced with GitHub -Apps integration further down the line. +Apps integration further down the line by using the existing `integrations` +config. + +> Note: Some of this configuration may already be set up as part of your +> `app-config.yaml`. We're moving away from the duplicated config for +> authentication in the `scaffolder` section and using `integrations` instead. #### GitHub @@ -187,10 +192,14 @@ by specifying `visibility` option. Valid options are `public`, `private` and public within the enterprise. ```yaml +integrations: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + scaffolder: github: - token: - $env: GITHUB_TOKEN visibility: public # or 'internal' or 'private' ``` @@ -201,10 +210,9 @@ allows to configure the private access token and the base URL of a GitLab instance: ```yaml -scaffolder: +integrations: gitlab: - api: - baseUrl: https://gitlab.com + - host: gitlab.com token: $env: GITLAB_TOKEN ``` @@ -218,10 +226,9 @@ will hopefully support on-prem installations as well but that has not been verified. ```yaml -scaffolder: +integrations: azure: - baseUrl: https://dev.azure.com/{your-organization} - api: + - host: dev.azure.com token: $env: AZURE_TOKEN ``` diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index cf94efda5e..f81f41668d 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -2,8 +2,8 @@ id: techdocs-overview title: TechDocs Documentation sidebar_label: Overview -description: TechDocs is Spotify’s homegrown docs-like-code solution built -directly into Backstage +# prettier-ignore +description: TechDocs is Spotify’s homegrown docs-like-code solution built directly into Backstage --- ## What is it? @@ -113,7 +113,7 @@ providers are used. | Local Filesystem of Backstage app | Yes ✅ | | Google Cloud Storage (GCS) | Yes ✅ | | Amazon Web Services (AWS) S3 | Yes ✅ | -| Azure Storage | No ❌ | +| Azure Blob Storage | Yes ✅ | [Reach out to us](#feedback) if you want to request more platforms. diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 7c35fbc943..8d25b4047f 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -12,32 +12,31 @@ out-of-the box experience. TechDocs Architecture diagram > Note: See below for our recommended deployment architecture which takes care -> of stability, scalability and speed. +> of stability, scalability and speed. Also look at the +> [HOW TO migrate guide](how-to-guides.md#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach). When you open a TechDocs site in Backstage, the [TechDocs Reader](./concepts.md#techdocs-reader) makes a request to -`techdocs-backend` with the entity ID and the path of the current page you are -looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to -render on the page in TechDocs/Backstage. +`techdocs-backend` plugin with the entity ID and the path of the current page +you are looking at. In response, it receives the static files (HTML, CSS, JSON, +etc.) to render on the page in TechDocs/Backstage. The static files consist of HTML, CSS and Images generated by MkDocs. We remove all the JavaScript before adding them to Backstage for security reasons. And -there are some additional techdocs metadata JSON files that TechDocs needs to -render a site. +there is an additional `techdocs_metadata.json` file that TechDocs needs to +render a site. It's important that you use either +[techdocs-cli](https://github.com/backstage/techdocs-cli) or +[techdocs-container](https://github.com/backstage/techdocs-container) to +generate the docs for the expected output. The TechDocs Reader then applies a list of "Transformers" (see [Concepts](./concepts.md)) which modify the generated static HTML files for a number of use cases e.g. Remove certain headers, filter out some HTML tags, etc. Currently, we use the Backstage server's (or techdocs-backend's) local file -system to store the generated files. Publishing to an external storage system -(AWS S3, GCS, etc.) is also possible, but has not been implemented yet. - -A word about `UrlReader` vs Git preparer - Right now, we have two ways to fetch -files from its source repository for docs site generation. 1. By using Git -and 2. By directly using Source control (GitHub, Azure, etc.) APIs. This work is -heavily in progress. Please reach out to us on Discord in the #docs-like-code -channel to talk about it. +system to store the generated files. However, it's ideal to use an external +storage system (e.g. AWS S3, GCS or Azure Blob Storage). Read more in +[using cloud storage](using-cloud-storage.md). ## Recommended deployment @@ -49,22 +48,16 @@ The key difference in the recommended deployment approach is where the docs are built. We assume each entity lives in a repository somewhere (GitHub, GitLab, etc.). We -recommend using a CI/CD pipeline with the repository that has a dedicated -step/job to generate docs for TechDocs. The generated static files are then -stored in a cloud storage solution of your choice. -[Track progress here](https://github.com/backstage/backstage/issues/3096). +recommend [using a CI/CD pipeline](configuring-ci-cd.md) with the repository +that has a dedicated step/job to generate docs for TechDocs. The generated +static files are then [stored in a cloud storage](using-cloud-storage.md) +solution of your choice. Similar to how it is done in the Basic setup, the TechDocs Reader requests `techdocs-backend` plugin for the docs site. `techdocs-backend` then requests your configured storage solution for the necessary files and returns them to TechDocs Reader. -We will provide instructions, scripts and/or templates (e.g. GitHub Actions) to -generate docs in your CI/CD system. -[Track progress here.](https://github.com/backstage/backstage/issues/3400) You -will be able to use `techdocs-cli` to generate docs and publish the generated -docs site files to your cloud storage system. - Note about caching: We have noticed internally that some storage providers can be quite slow, which is why we are recommending a cache that sits between the TechDocs Reader and the Storage. @@ -97,7 +90,7 @@ permissions to publish the generated docs site files. **Q: Why do you have separate "basic" and "recommended" deployment approaches?** A: The basic or out-of-the-box setup is what you get when you create a new app -or do a git clone of the Backstage repository. We want the first experience to +or do a `git clone` of the Backstage repository. We want the first experience to _just work magically_ so that you can have your first experience with TechDocs which is smooth. However, if you decide to deploy Backstage/TechDocs for production use, the basic setup would work but there are going to be downsides @@ -109,19 +102,35 @@ and we welcome such "Alternative" ideas from the community. **Q: Why don't you recommend techdocs-backend local filesystem to serve static files?** -A: It would make scaling a Backstage instance harder. Think about the case where -we have distributed Backstage deployments. Using a separate file storage system -for TechDocs makes it easier to do some operations like delete a docs site and -wipe its contents. +A: It makes scaling a Backstage instance harder. Think about the case where we +have distributed Backstage deployments (e.g. multiple Kubernetes pods of your +Backstage app). Using a separate/central file storage system for TechDocs is +necessary to make sure sites are persistent when the servers/pods are restarted +and avoid duplicating sites per instance. By having an external storage, we are +making it easier to do some operations like delete a docs site or wipe its +contents. **Q: Why aren't docs built on the fly i.e. when users visits a page, generate docs site in real-time?** -A: Generating the content from Markdown on the fly is not optimal (although that -is how the basic out-of-the-box setup is implemented). Storage solutions act as -a cache for the generated static content. TechDocs is also currently built on -MkDocs which does not allow us to generate docs per-page, so we would have to -build all docs for a entity on every request. +A: Generating the content from Markdown on the fly is not optimal. Storage +solutions act as a cache for the generated static content. TechDocs is also +currently built on MkDocs which does not allow us to generate docs per-page, so +we would have to build all docs for a entity on every request. + +**Q. Can you use the techdocs plugin without the techdocs-backend plugin?** + +A: `techdocs` and `techdocs-backend` plugins are designed to be used together, +like any other Backstage plugin with a frontend and its backend (catalog, +scaffolder, etc.). If you set your Backstage instance to generate docs on the +server, `techdocs-backend` will be responsible for managing the whole build +process, making sure it's scalable. It is responsible for securely communicating +with the cloud storage provider, for both fetching static generated sites and +publishing the updates. There are other planned features like an authentication +layer for users to determine whether they have the permission to view a +particular docs site. There are a handful of features which are extremely hard +to develop without a tightly integrated backend in place. Hence, support for +`techdocs` without `techdocs-backend` is limited and challenging to develop. # Future work @@ -132,22 +141,5 @@ We currently depend on MkDocs to parse doc sites written in Markdown. And we store the generated static assets and re-use it later to render in Backstage. A better (futuristic) approach will be to directly parse whatever type of source files you have in your docs repository and directly render in Backstage in -real-time. - -# Features status - -Status of all the features mentioned above. - -**In place ✅** - -- Basic setup with techdocs-backend file server as storage. -- Basic setup with cloud storage solution. - -**Work in progress 🚧** - -- `techdocs-cli` is able to generate docs in CI/CD environment. -- `techdocs-cli` is able to publish docs site to any storage. - -**Not implemented yet ❌** - -- `techdocs-backend` integration with Backstage access control management. +real-time. You can read more in this +[RFC - Simplifying TechDocs Frontend Architecture](https://github.com/backstage/backstage/issues/3998). diff --git a/docs/features/techdocs/concepts.md b/docs/features/techdocs/concepts.md index 26254a7e12..408f092ebd 100644 --- a/docs/features/techdocs/concepts.md +++ b/docs/features/techdocs/concepts.md @@ -1,8 +1,8 @@ --- id: concepts title: Concepts -description: Documentation on concepts that are introduced with -Spotify's docs-like-code solution in Backstage +# prettier-ignore +description: Documentation on concepts that are introduced with Spotify's docs-like-code solution in Backstage --- This page describes concepts that are introduced with Spotify's docs-like-code diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 1190c219a8..20e436d8a4 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -1,8 +1,8 @@ --- id: configuration title: TechDocs Configuration Options -description: - Reference documentation for configuring TechDocs using app-config.yaml +# prettier-ignore +description: Reference documentation for configuring TechDocs using app-config.yaml --- Using the `app-config.yaml` in the Backstage app, you can configure TechDocs @@ -13,18 +13,6 @@ configuration options for TechDocs. # File: app-config.yaml techdocs: - - # TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. - - requestUrl: http://localhost:7000/api/techdocs - - - # Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware - # to serve files from either a local directory or an External storage provider. - - storageUrl: http://localhost:7000/api/techdocs/static/docs - - # generators.techdocs can have two values: 'docker' or 'local'. This is to determine how to run the generator - whether to # spin up the techdocs-container docker image or to run mkdocs locally (assuming all the dependencies are taken care of). # You want to change this to 'local' if you are running Backstage using your own custom Docker setup and want to avoid running @@ -34,7 +22,6 @@ techdocs: generators: techdocs: 'docker' - # techdocs.builder can be either 'local' or 'external. # If builder is set to 'local' and you open a TechDocs page, techdocs-backend will try to generate the docs, publish to storage # and show the generated docs afterwords. This is the "Basic" setup of the TechDocs Architecture. @@ -44,46 +31,76 @@ techdocs: builder: 'local' - # techdocs.publisher is used to configure the Storage option, whether you want to use the local filesystem to store generated docs # or you want to use External storage providers like Google Cloud Storage, AWS S3, etc. publisher: - - # techdocs.publisher.type can be - 'local' or 'googleGcs' (awsS3, azureStorage, etc. to be available as well). + # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' or 'azureBlobStorage'. # When set to 'local', techdocs-backend will create a 'static' directory at its root to store generated documentation files. # When set to 'googleGcs', techdocs-backend will use a Google Cloud Storage Bucket to store generated documentation files. # When set to 'awsS3', techdocs-backend will use an Amazon Web Service (AWS) S3 bucket to store generated documentation files. type: 'local' - # Required when techdocs.publisher.type is set to 'googleGcs'. Skip otherwise. googleGcs: - # An API key is required to write to a storage bucket. + # (Required) Cloud Storage Bucket Name + bucketName: 'techdocs-storage' + + # (Optional) An API key is required to write to a storage bucket. + # If missing, GOOGLE_APPLICATION_CREDENTIALS environment variable will be used. + # https://cloud.google.com/docs/authentication/production credentials: - $file: '/path/to/google_application_credentials.json', - - # Your GCP Project ID where the Cloud Storage Bucket is hosted. - projectId: 'gcp-project-id' - - # Cloud Storage Bucket Name - bucketName: 'techdocs-storage', + $file: '/path/to/google_application_credentials.json' # Required when techdocs.publisher.type is set to 'awsS3'. Skip otherwise. awsS3: - # An API key is required to write to a storage bucket. + # (Required) AWS S3 Bucket Name + bucketName: 'techdocs-storage' + + # (Optional) An API key is required to write to a storage bucket. + # If not set, environment variables or aws config file will be used to authenticate. + # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html + # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html credentials: accessKeyId: $env: TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL secretAccessKey: $env: TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL + + # (Optional) AWS Region of the bucket. + # If not set, AWS_REGION environment variable or aws config file will be used. + # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html region: - $env: AWSS3_REGION + $env: AWS_REGION - # AWS S3 Bucket Name - bucketName: 'techdocs-storage', + # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. + azureBlobStorage: + # (Required) Azure Blob Storage Container Name + containerName: 'techdocs-storage' + + # (Required) An account name is required to write to a storage blob container. + # https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME + # (Optional) An account key is required to write to a storage container. + # If missing,AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET environment variable will be used. + # https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + accountKey: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY + + # (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. + # You don't have to specify this anymore. + + requestUrl: http://localhost:7000/api/techdocs + + # (Optional and Legacy) Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware + # to serve files from either a local directory or an External storage provider. + # You don't have to specify this anymore. + + storageUrl: http://localhost:7000/api/techdocs/static/docs ``` diff --git a/docs/features/techdocs/configuring-ci-cd.md b/docs/features/techdocs/configuring-ci-cd.md new file mode 100644 index 0000000000..5841a02fa8 --- /dev/null +++ b/docs/features/techdocs/configuring-ci-cd.md @@ -0,0 +1,98 @@ +--- +id: configuring-ci-cd +title: Configuring CI/CD to generate and publish TechDocs sites +# prettier-ignore +description: Configuring CI/CD to generate and publish TechDocs sites to cloud storage +--- + +In the [Recommended deployment setup](./architecture.md#recommended-deployment), +TechDocs reads the static generated documentation files from a cloud storage +bucket (GCS, AWS S3, etc.). The documentation site is generated on the CI/CD +workflow associated with the repository containing the documentation files. This +document explains the steps needed to generate docs on CI and publish to a cloud +storage using [`techdocs-cli`](https://github.com/backstage/techdocs-cli). + +The steps here target all kinds of CI providers (GitHub Actions, CircleCI, +Jenkins, etc.). Specific tools for individual providers will also be made +available here for simplicity (e.g. a GitHub Actions runner, CircleCI orb, +etc.). + +A summary of the instructions below looks like this - + +```sh +# This is an example script + +# Prepare +REPOSITORY_URL='https://github.com/org/repo' +git clone $REPOSITORY_URL +cd repo + +# Generate +npx @techdocs/cli generate + +# Publish +npx @techdocs/cli publish --publisher-type awsS3 --storage-name --entity +``` + +That's it! + +Take a look at +[`techdocs-cli` README](https://github.com/backstage/techdocs-cli) for the +complete command reference, details, and options. + +## 1. Setup a workflow + +The TechDocs workflow should trigger on CI when any changes are made in the +repository containing the documentation files. You can be specific and configure +the workflow to be triggered only when files inside the `docs/` directory or +`mkdocs.yml` are changed. + +## 2. Prepare step + +The first step on the CI is to clone your documentation source repository in a +working directory. This is almost always the first step in most CI workflows. + +On GitHub Actions, you can add a step + +[`- uses: actions@checkout@v2`](https://github.com/actions/checkout). + +On CircleCI, you can add a special +[`checkout`](https://circleci.com/docs/2.0/configuration-reference/#checkout) +step. + +Eventually we are trying to do a `git clone `. + +## 3. Generate step + +Install [`npx`](https://www.npmjs.com/package/npx) to use it for running +`techdocs-cli`. Or you can install using `npm install -g @techdocs/cli`. + +We are going to use the +[`techdocs-cli generate`](https://github.com/backstage/techdocs-cli#generate-techdocs-site-from-a-documentation-project) +command in this step. + +```sh +npx @techdocs/cli generate --no-docker --source-dir PATH_TO_REPO --output-dir ./site +``` + +`PATH_TO_REPO` should be the location in the file path where the prepare step +above clones the repository. + +## 4. Publish step + +Depending on your cloud storage provider (AWS, Google Cloud, or Azure), set the +necessary authentication environment variables. + +- [Google Cloud authentication](https://cloud.google.com/storage/docs/authentication#libauth) +- [AWS authentication](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html) + +And then run the +[`techdocs-cli publish`](https://github.com/backstage/techdocs-cli#publish-generated-techdocs-sites) +command. + +```sh +npx @techdocs/cli publish --publisher-type --storage-name --entity --directory ./site +``` + +The updated TechDocs site built in this workflow is now ready to be served by +the TechDocs plugin in your Backstage app. diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index 187fe97aed..b9340330a8 100644 --- a/docs/features/techdocs/creating-and-publishing.md +++ b/docs/features/techdocs/creating-and-publishing.md @@ -66,13 +66,24 @@ Update your component's entity description by adding the following lines to its ```yaml metadata: annotations: - backstage.io/techdocs-ref: dir:./ + backstage.io/techdocs-ref: url:https://github.com/org/repo + # Or + # backstage.io/techdocs-ref: url:https://github.com/org/repo/tree/branchName/subFolder ``` +The +[`backstage.io/techdocs-ref` annotation](../software-catalog/well-known-annotations.md#backstageiotechdocs-ref) +is used by TechDocs to download the documentation source files for generating an +Entity's TechDocs site. + Create a `/docs` folder in the root of the project with at least an `index.md` file. _(If you add more markdown files, make sure to update the nav in the mkdocs.yml file to get a proper navigation for your documentation.)_ +> Note - Although `docs` is a popular directory name for storing documentation, +> it can be renamed to something else and can be configured by `mkdocs.yml`. See +> https://www.mkdocs.org/user-guide/configuration/#docs_dir + The `docs/index.md` can for example have the following content: ```md diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index e1ddfd23f9..e0b323857d 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -150,26 +150,10 @@ app. Now let us tweak some configurations to suit your needs. **See [TechDocs Configuration Options](configuration.md) for complete configuration reference.** -### Setting TechDocs URLs - -```yaml -techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ -``` - -`requestUrl` is used by TechDocs frontend plugin to discover techdocs-backend -endpoints, and the `storageUrl` is another endpoint in `techdocs-backend` which -acts as a middleware between TechDocs and the storage (where the static -generated docs site are stored). These default values should mostly work for -you. These options will soon be optional to set. - ### Should TechDocs Backend generate docs? ```yaml techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ builder: 'local' ``` @@ -180,8 +164,8 @@ pipeline, and are being stored in a storage somewhere. When `techdocs.builder` is set to `'external'`, TechDocs becomes more or less a read-only experience where it serves static files from a storage containing all -the generated documentation. Read more in the "Basic" and "Recommended" setup of -TechDocs [here](architecture.md) +the generated documentation. Read more in the "Basic" and "Recommended" sections +of the [TechDocs Architecture](architecture.md). ### Choosing storage (publisher) @@ -190,14 +174,12 @@ fetch the sites from. This is managed by a [Publisher](./concepts.md#techdocs-publisher). Examples: Google Cloud Storage, Amazon S3, or local filesystem of Backstage server. -It is okay to use the local filesystem in a "Basic" setup when you are trying -out Backstage for the first time. Using Cloud Storage is documented -[here](./using-cloud-storage.md). +It is okay to use the local filesystem in a "basic" setup when you are trying +out Backstage for the first time. At a later time, review +[Using Cloud Storage](./using-cloud-storage.md). ```yaml techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ builder: 'local' publisher: type: 'local' @@ -219,6 +201,9 @@ no config is provided. ```yaml techdocs: + builder: 'local' + publisher: + type: 'local' generators: techdocs: local ``` @@ -229,23 +214,23 @@ environment is compatible with techdocs. You will have to install the `mkdocs` and `mkdocs-techdocs-core` package from pip, as well as `graphviz` and `plantuml` from your OS package manager (e.g. apt). See our -[Dockerfile](https://github.com/backstage/techdocs-container/blob/main/Dockerfile) -for the latest requirements. You should be trying to match your Dockerfile with -this one. +[`Dockerfile`](https://github.com/backstage/techdocs-container/blob/main/Dockerfile) +for the latest requirements. You should be trying to match your `Dockerfile` +with this one. Note: We recommend Python version 3.7 or higher. -Caveat: Please install the `mkdocs-techdocs-core` package after all other Python -packages. The order is important to make sure we get correct version of some of -the dependencies. For example, we want `Markdown` version to be -[3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11). -You can also explicitly install `Markdown==3.2.2` after installing all other -Python packages. +> Caveat: Please install the `mkdocs-techdocs-core` package after all other +> Python packages. The order is important to make sure we get correct version of +> some of the dependencies. For example, we want `Markdown` version to be +> [3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11). +> You can also explicitly install `Markdown==3.2.2` after installing all other +> Python packages. ## Running Backstage locally Start the frontend and the backend app by -[running backstage locally](../../getting-started/running-backstage-locally.md). +[running Backstage locally](../../getting-started/running-backstage-locally.md). Open your browser at [http://localhost:3000/docs/](http://localhost:3000/docs/) to see all your documentation sites. diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md new file mode 100644 index 0000000000..2cb769b0e3 --- /dev/null +++ b/docs/features/techdocs/how-to-guides.md @@ -0,0 +1,84 @@ +--- +id: how-to-guides +title: TechDocs "HOW TO" guides +sidebar_label: "HOW TO" guides +description: TechDocs "HOW TO" guides related to TechDocs +--- + +## How to migrate from TechDocs Basic to Recommended deployment approach? + +The main difference between TechDocs Basic and Recommended deployment approach +is where the docs are generated and stored. In Basic or the out-of-the-box +setup, docs are generated and stored at the server running your Backstage +instance. But the recommended setup is to generate docs on CI/CD and store the +generated sites to an external storage (e.g. AWS S3 or GCS). TechDocs in your +Backstage instance should turn into read-only mode. Read more details and the +benefits in the [TechDocs Architecture](architecture.md). + +Here are the steps needed to switch from the Basic to Recommended setup - + +### 1. Prepare a cloud storage + +Choose a cloud storage provider like AWS, Google Cloud or Microsoft Azure. +Follow the detailed instructions for +[using cloud storage](using-cloud-storage.md) in TechDocs. + +### 2. Publish to storage from CI/CD + +Start publishing your TechDocs sites from the CI/CD workflow of each repository +containing the source markdown files. Read the detailed instructions for +[configuring CI/CD](configuring-ci-cd.md). + +### 3. Switch TechDocs to read-only mode + +In your Backstage instance's `app-config.yaml`, set `techdocs.builder` from +`'local'` to `'external'`. By doing this, TechDocs will not try to generate +docs. Look at [TechDocs configuration](configuration.md) for reference. + +## How to use URL Reader in TechDocs Prepare step? + +If TechDocs is configured to generate docs, it will first download the +repository associated with the `backstage.io/techdocs-ref` annotation defined in +the Entity's `catalog-info.yaml` file. This is also called the +[Prepare](./concepts.md#techdocs-preparer) step. + +There are two kinds of preparers or two ways of downloading these source files + +- Preparer 1: Doing a `git clone` of the repository (also known as Common Git + Preparer) +- Preparer 2: Downloading an archive.zip or equivalent of the repository (also + known as URL Reader) + +If `backstage.io/techdocs-ref` is equal to any of these - + +1. `github:https://githubhost.com/org/repo` +2. `gitlab:https://gitlabhost.com/org/repo` +3. `bitbucket:https://bitbuckethost.com/project/repo` +4. `azure/api:https://azurehost.com/org/project` + +Then Common Git Preparer will be used i.e. a `git clone`. But the URL Reader is +a much faster way to do this step. Convert the `backstage.io/techdocs-ref` +values to the following - + +1. `url:https://githubhost.com/org/repo/tree/` +2. `url:https://gitlabhost.com/org/repo/tree/` +3. `url:https://bitbuckethost.com/project/repo/src/` +4. `url:https://azurehost.com/organization/project/_git/repository` + +Note that you can also provide a path to a non-root directory inside the +repository which contains the `docs/` directory. + +e.g. +`url:https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/examples/documented-component` + +### Why is URL Reader faster than a git clone? + +URL Reader uses the source code hosting provider to download a zip or tarball of +the repository. The archive does not have any git history attached to it. Also +it is a compressed file. Hence the file size is significantly smaller than how +much data git clone has to transfer. + +Caveat: Currently TechDocs sites built using URL Reader will be cached for 30 +minutes which means they will not be re-built if new changes are made within 30 +minutes. This cache invalidation will be replaced by commit timestamp based +implementation very soon. diff --git a/docs/features/techdocs/troubleshooting.md b/docs/features/techdocs/troubleshooting.md index 3a9b2fcfd0..e6efefc578 100644 --- a/docs/features/techdocs/troubleshooting.md +++ b/docs/features/techdocs/troubleshooting.md @@ -5,6 +5,53 @@ 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. +## Failure to clone + +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. + +## MkDocs Build Errors + +Using the [TechDocs CLI](https://github.com/backstage/techdocs-cli), you can +troubleshoot MkDocs build issues locally. Note this requires you have Docker +available to launch images. First, `git clone` the target repository locally, +then in the root of the repository, run: + +``` +npx @techdocs/cli serve +``` + +For example, if you have forgotten to put an MkDocs configuration file in your +repo, the resulting error will be: + +``` +npx: installed 278 in 9.089s +[techdocs-preview-bundle] Running local version of Backstage at http://localhost:3000 +INFO - Building documentation... + +Config file '/content/mkdocs.yml' does not exist. +``` + +When it works, a local copy of both Backstage and your site will be launched +locally: + +``` +npx: installed 278 in 9.682s +[techdocs-preview-bundle] Running local version of Backstage at http://localhost:3000 +INFO - Building documentation... +WARNING - Config value: 'dev_addr'. Warning: The use of the IP address '0.0.0.0' + suggests a production environment or the use of a proxy to connect to the MkDocs + server. However, the MkDocs' server is intended for local development purposes only. + Please use a third party production-ready server instead. +INFO - Cleaning site directory +DEBUG - Successfully imported extension module "plantuml_markdown". +DEBUG - Successfully loaded extension "plantuml_markdown.PlantUMLMarkdownExtension". +INFO - Documentation built in 0.23 seconds +[I 210115 19:00:45 server:335] Serving on http://0.0.0.0:8000 +INFO - Serving on http://0.0.0.0:8000 +[I 210115 19:00:45 handlers:62] Start watching changes +INFO - Start watching changes +[I 210115 19:00:45 handlers:64] Start detecting changes +INFO - Start detecting changes +``` diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index c05346ca4a..de2d505c87 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -30,20 +30,35 @@ techdocs: type: 'googleGcs' ``` -**2. GCP (Google Cloud Platform) Project** +**2. Create a GCS Bucket** -Create or choose a dedicated GCP project. Set -`techdocs.publisher.googleGcs.projectId` to the project ID. +Create a dedicated Google Cloud Storage bucket for TechDocs sites. +techdocs-backend will publish documentation to this bucket. TechDocs will fetch +files from here to serve documentation in Backstage. Note that the bucket names +are globally unique. + +Set the config `techdocs.publisher.googleGcs.bucketName` in your +`app-config.yaml` to the name of the bucket you just created. ```yaml techdocs: publisher: type: 'googleGcs' - googleGcs: - projectId: 'gcp-project-id' + googleGcs: + bucketName: 'name-of-techdocs-storage-bucket' ``` -**3. Service account API key** +**3a. (Recommended) Authentication using environment variable** + +The GCS Node.js client will automatically use the environment variable +`GOOGLE_APPLICATION_CREDENTIALS` to authenticate with Google Cloud. It might +already be set in Compute Engine, Google Kubernetes Engine, etc. Read +https://cloud.google.com/docs/authentication/production for more details. + +**3b. Authentication using app-config.yaml** + +If you do not prefer (3a) and optionally like to use a service account, you can +follow these steps. Create a new Service Account and a key associated with it. In roles of the service account, use "Storage Admin". @@ -65,41 +80,32 @@ techdocs: publisher: type: 'googleGcs' googleGcs: - projectId: 'gcp-project-id' + bucketName: 'name-of-techdocs-storage-bucket' credentials: $file: '/path/to/google_application_credentials.json' ``` -**4. GCS Bucket** - -Create a dedicated bucket for TechDocs sites. techdocs-backend will publish -documentation to this bucket. TechDocs will fetch files from here to serve -documentation in Backstage. - -Set the name of the bucket to `techdocs.publisher.googleGcs.bucketName`. +Note: If you are finding it difficult to make the file +`google_application_credentials.json` available on a server, you could use the +file's content and set as an environment variable. And then use ```yaml techdocs: publisher: type: 'googleGcs' googleGcs: - projectId: 'gcp-project-id' - credentials: - $file: '/path/to/google_application_credentials.json' bucketName: 'name-of-techdocs-storage-bucket' + credentials: + $env: GOOGLE_APPLICATION_CREDENTIALS ``` -**5. That's it!** +**4. That's it!** Your Backstage app is now ready to use Google Cloud Storage for TechDocs, to -store the static generated documentation files. +store and read the static generated documentation files. ## Configuring AWS S3 Bucket with TechDocs -Follow the -[official AWS S3 documentation](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html) -for the latest instructions on the following steps involving AWS S3. - **1. Set `techdocs.publisher.type` config in your `app-config.yaml`** Set `techdocs.publisher.type` to `'awsS3'`. @@ -110,43 +116,17 @@ techdocs: type: 'awsS3' ``` -**2. AWS Policies** +**2. Create an S3 Bucket** -AWS Policies lets you **control access** to Amazon Web Services (AWS) products -and resources. -Here we will use a user policy **and** a bucket policy to show you the different -possibilities you have but you can use only one. +Create a dedicated AWS S3 bucket for the storage of TechDocs sites. +[Refer to the official documentation](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/create-bucket.html). -AWS S3 +TechDocs will publish documentation to this bucket and will fetch files from +here to serve documentation in Backstage. Note that the bucket names are +globally unique. -This is an example of how you can manage your policies: - -a. Admin user creates a **bucket policy** granting a set of permissions to our -TechDocs user. - -b. Admin user attaches a **user policy** to the TechDocs user granting -additional permissions. - -c. TechDocs User then tries permissions granted via both the **bucket** policy -and the **user** policy. - -**2.1 Creation** - -**2.1.1 Create an Admin user** (if you don't have one yet) - -Create an **administrator user** account `ADMIN_USER` and grant it administrator -privileges by attaching a user policy giving the account **full access**. -Note down the Admin User credentials and IAM User Sign-In URL as you will need -to use this information in the next step. - -**2.1.2 Create an AWS S3 Bucket** - -Using the credentials of your Admin User `ADMIN_USER`, and the special IAM user -sign-in URL, create a dedicated **bucket** for TechDocs sites. techdocs-backend -will publish documentation to this bucket. TechDocs will fetch files from here -to serve documentation in Backstage. - -Set the name of the bucket to `techdocs.publisher.awsS3.bucketName`. +Set the config `techdocs.publisher.awsS3.bucketName` in your `app-config.yaml` +to the name of the bucket you just created. ```yaml techdocs: @@ -156,112 +136,153 @@ techdocs: bucketName: 'name-of-techdocs-storage-bucket' ``` -**2.1.3 Create the `TechDocs` user** +**3a. (Recommended) Setup authentication the AWS way, using environment +variables** -This user will be used to interact with your bucket, it will only have -permissions to **get - put** objects. +You should follow the +[AWS security best practices guide for authentication](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html). -In the IAM console, do the following: +If the environment variables -- Create a new user, `TechDocs` -- Note down the TechDocs User credentials -- Note down the Amazon Resource Name (ARN) for the TechDocs user. In the IAM - console, select the TechDocs user, and you can find the user ARN in the - Summary tab. +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` +- `AWS_REGION` -**2.2 Attach policies** +are set and can be used to access the bucket you created in step 2, they will be +used by the AWS SDK v3 Node.js client for authentication. +[Refer to the official documentation.](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html) -Remember that you can use Bucket policy **or** User policy. -Just make sure that you grant all the permissions to the TechDocs user: -`3:PutObject`, `s3:GetObject`, `s3:ListBucket` and `s3:GetBucketLocation`. +If the environment variables are missing, the AWS SDK tries to read the +`~/.aws/credentials` file for credentials. +[Refer to the official documentation.](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html) -**2.2.1 Create the bucket policy** +Note that the region of the bucket has to be set for the AWS SDK to work. +[See this](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html). -You now have to attach the following policy to your bucket in the Permission -section: +**3b. Authentication using app-config.yaml** -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "statement1", - "Effect": "Allow", - "Principal": { - "AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:user/TechDocs" - }, - "Action": ["s3:GetBucketLocation", "s3:ListBucket"], - "Resource": ["arn:aws:s3:::name-of-techdocs-storage-bucket"] - }, - { - "Sid": "statement2", - "Effect": "Allow", - "Principal": { - "AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:user/TechDocs" - }, - "Action": ["s3:GetObject"], - "Resource": ["arn:aws:s3:::name-of-techdocs-storage-bucket/*"] - } - ] -} -``` - -- The first statement grants **TechDocs User** the bucket operation permissions - `s3:GetBucketLocation` and `s3:ListBucket` which are permissions required by - the console. -- The second statement grants the `s3:GetObject` permission. - (**NOTE :** if you do not use the user policy defined below you must also add - the `s3:PutObject` permission to allow the TechDocs user to add objects.) - -**2.2.2 Create the user policy** - -Create an inline policy for the TechDocs user by using the following policy: - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "PermissionForObjectOperations", - "Effect": "Allow", - "Action": ["s3:PutObject"], - "Resource": ["arn:aws:s3:::name-of-techdocs-storage-bucket/*"] - } - ] -} -``` - -See more details in the section -[Working with Inline Policies](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_manage.html). - -Now you need to fill in the environment variables with the `TechDocs` User -credentials. You can also specify a region if you want to accesses the resources -in a specific region. Otherwise no region will be selected by default. - -```properties -TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL="TECHDOCS_ACCESS_KEY_ID" -TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL="TECHDOCS_SECRET_ACCESS_KEY" -AWSS3_REGION="" // Optional -``` - -Make it available in your Backstage server and/or your local development server -and set it in the app config techdocs.publisher.awsS3. +AWS credentials and region can be provided to the AWS SDK via `app-config.yaml`. +If the configs below are present, they will be used over existing `AWS_*` +environment variables and the `~/.aws/credentials` config file. ```yaml techdocs: publisher: type: 'awsS3' awsS3: + bucketName: 'name-of-techdocs-storage-bucket' + region: + $env: AWS_REGION credentials: accessKeyId: - $env: TECHDOCS_AWSS3_ACCESS_KEY_ID_CREDENTIAL + $env: AWS_ACCESS_KEY_ID secretAccessKey: - $env: TECHDOCS_AWSS3_SECRET_ACCESS_KEY_CREDENTIAL - region: - $env: AWSS3_REGION + $env: AWS_SECRET_ACCESS_KEY ``` -**3. That's it!** +Refer to the +[official AWS documentation for obtaining the credentials](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html). -Your Backstage app is now ready to use AWS S3 for TechDocs, to store the static -generated documentation files. +Note: If you are using Amazon EC2 instance to deploy Backstage, you do not need +to obtain the access keys separately. They can be made available in the +environment automatically by defining appropriate IAM role with access to the +bucket. Read more +[here](https://docs.aws.amazon.com/general/latest/gr/aws-access-keys-best-practices.html#use-roles). + +**4. That's it!** + +Your Backstage app is now ready to use AWS S3 for TechDocs, to store and read +the static generated documentation files. When you start the backend of the app, +you should be able to see +`techdocs info Successfully connected to the AWS S3 bucket` in the logs. + +## Configuring Azure Blob Storage Container with TechDocs + +Follow the +[official Azure Blob Storage documentation](https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json) +for the latest instructions on the following steps involving Azure Blob Storage. + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'azureBlobStorage'`. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' +``` + +**2. Create an Azure Blob Storage Container** + +Create a dedicated container for TechDocs sites. +[Refer to the official documentation](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-portal). + +TechDocs will publish documentation to this container and will fetch files from +here to serve documentation in Backstage. Note that the container names are +globally unique. + +Set the config `techdocs.publisher.azureBlobStorage.containerName` in your +`app-config.yaml` to the name of the container you just created. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-container' +``` + +**3a. (Recommended) Authentication using environment variable** + +Set the config `techdocs.publisher.azureBlobStorage.credentials.accountName` in +your `app-config.yaml` to the your account name. + +The storage blob client will automatically use the environment variable +`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` to authenticate with +Azure Blob Storage. +[Steps to create the service where the variables can be retrieved from](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal). + +https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad for more +details. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-bucket' + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME +``` + +**3b. Authentication using app-config.yaml** + +If you do not prefer (3a) and optionally like to use a service account, you can +follow these steps. + +To get credentials, access the Azure Portal and go to "Settings > Access Keys", +and get your Storage account name and Primary Key. +https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key +for more details. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-bucket' + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME + accountKey: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY +``` + +**4. That's it!** + +Your Backstage app is now ready to use Azure Blob Storage for TechDocs, to store +and read the static generated documentation files. When you start the backend of +the app, you should be able to see +`techdocs info Successfully connected to the Azure Blob Storage container` in +the logs. diff --git a/docs/getting-started/create-an-app.md b/docs/getting-started/create-an-app.md index 52ea90db5b..0663d3faa1 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/getting-started/create-an-app.md @@ -74,6 +74,22 @@ those plugins in your backend. This is because the transformation of backend module tree stops whenever a non-local package is encountered, and from that point node will `require` packages directly for that entire module subtree. +Type checking can also have issues when linking in external packages, since the +linked in packages will use the types in the external project and dependency +version mismatches between the two projects may cause errors. To fix any of +those errors you need to sync versions of the dependencies in the two projects. +A simple way to do this can be to copy over `yarn.lock` from the external +project and run `yarn install`, although this is quite intrusive and can cause +other issues in existing projects, so use this method with care. It can often be +best to simply ignore the type errors, as app serving will work just fine +anyway. + +Another issue with type checking is that the incremental type cache doesn't +invalidate correctly for the linked in packages, causing type checking to not +reflect changes made to types. You can work around this by either setting +`compilerOptions.incremental = false` in `tsconfig.json`, or by deleting the +types cache folder `dist-types` before running `yarn tsc`. + ### Troubleshooting The create app command doesn't always work as expected, this is a collection of diff --git a/docs/getting-started/deployment-other.md b/docs/getting-started/deployment-other.md index cfd2e903d9..e92b54d08b 100644 --- a/docs/getting-started/deployment-other.md +++ b/docs/getting-started/deployment-other.md @@ -4,19 +4,97 @@ title: Other description: Documentation on different ways of Deployment --- -## Deploying Locally +## Docker -### Try on Docker +Here we have an example Dockerfile that you can use to build everything together +in one container. This Dockerfile uses multi-stage builds, and a +`backend:bundle` command from the CLI. -Run the following commands if you have Docker environment +It also provides caching on the `yarn install`'s so that you don't have to do it +unless absolutely necessary. -```bash -$ yarn install -$ yarn docker-build -$ docker run --rm -it -p 7000:7000 -e APP_ENV=production -e NODE_ENV=development example-backend:latest +> Note: This Dockerfile assumes that you're running SQLite, or your +> configuration is setup to connect to an external PostgreSQL Database. + +```Dockerfile +# Stage 1 - Create yarn install skeleton layer +FROM node:14-buster AS packages + +WORKDIR /app +COPY package.json yarn.lock ./ + +COPY packages packages + +# Uncomment this line if you have a local plugins folder +# COPY plugins plugins + +RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf + +# Stage 2 - Install dependencies and build packages +FROM node:14-buster AS build + +WORKDIR /app +COPY --from=packages /app . + +RUN yarn install --network-timeout 600000 && rm -rf "$(yarn cache dir)" + +COPY . . + +RUN yarn tsc +RUN yarn --cwd packages/backend backstage-cli backend:bundle --build-dependencies + +# Stage 3 - Build the actual backend image and install production dependencies +FROM node:14-buster + +WORKDIR /app + +# Copy from build stage +COPY --from=build /app/yarn.lock /app/package.json /app/packages/backend/dist/skeleton.tar.gz ./ +RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz + +RUN yarn install --production --network-timeout 600000 && rm -rf "$(yarn cache dir)" + +COPY --from=build /app/packages/backend/dist/bundle.tar.gz . +RUN tar xzf bundle.tar.gz && rm bundle.tar.gz + +COPY app-config.yaml app-config.production.yaml ./ + +CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.production.yaml"] ``` -Then open http://localhost/ on your browser. +Before building you should also include a `.dockerignore`. This will greatly +improve the context boot up time of Docker as we are no longer sending all of +the `node_modules` into the context. It also helps us avoid some limitations and +errors that may occur when trying to share the `node_modules` folder to inside +the build. + +You can add the following contents to the root of your repository at +`.dockerignore` and it might look something like the following: + +```dockerignore +.git +node_modules +packages/*/node_modules +plugins/*/node_modules +plugins/*/dist +``` + +Once you have added both the `Dockerfile` and `.dockerignore` to the root of +your project, and run the following to build the container under a specified +tag. + +```sh +$ docker build -t example-deployment . +``` + +To run the image locally you can run: + +```sh +$ docker run -it -p 7000:7000 example-deployment +``` + +You should then start to get logs in your terminal, and then you can open your +browser at `http://localhost:7000` ## Heroku diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index c5275114ce..01fd7bdc24 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -1,8 +1,8 @@ --- id: development-environment title: Development Environment -description: Documentation on how to get set up for doing development on -the Backstage repository +# prettier-ignore +description: Documentation on how to get set up for doing development on the Backstage repository --- This section describes how to get set up for doing development on the Backstage @@ -31,7 +31,7 @@ $ yarn start This should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. -By default, backstage will start on port 3000, however you can override this by +By default, Backstage will start on port 3000, however you can override this by setting an environment variable `PORT` on your local machine. e.g. `export PORT=8080` then running `yarn start`. Or `PORT=8080 yarn start`. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000000..e5a9882909 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,21 @@ +--- +id: glossary +title: Backstage Glossary +# prettier-ignore +description: List of all the terms, abbreviations, and phrases used in Backstage, together with their explanations. +--- + +The Backstage Glossary lists all the terms, abbreviations, and phrases used in +Backstage, together with their explanations. We encourage you to use the +terminology below for clarity and consistency when discussing Backstage. + +### Backstage User Profiles + +There are three main user profiles for Backstage: the integrator, the +contributor, and the software engineer. + +| Term | Explanation | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Integrator | The **integrator** hosts the Backstage app and configures which plugins are available to use in the app. | +| Contributor | The **contributor** adds functionality to the app by writing plugins. | +| Software Engineer | The **software engineer** uses the app's functionality and interacts with its plugins. In practice, this profile covers the various roles that help deliver software, from the Software Engineer themselves, to Designers, Data Scientists, Product Owners, Engineering Managers, etc. | diff --git a/docs/overview/adopting.md b/docs/overview/adopting.md index 0d404d5cb4..152be21a2c 100644 --- a/docs/overview/adopting.md +++ b/docs/overview/adopting.md @@ -1,8 +1,8 @@ --- id: adopting title: Strategies for adopting -description: Documentation on some general best practices that have been key -to Backstage's success inside Spotify +# prettier-ignore +description: Documentation on some general best practices that have been key to Backstage's success inside Spotify --- This document outlines some general best practices that have been key to diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index d8069c8665..f2f2bc72c8 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -185,17 +185,6 @@ separate Docker images. ![Boxes around the architecture to indicate how it is containerised](../assets/architecture-overview/containerised.png) -The frontend container can be built with a provided command. - -```bash -yarn install -yarn tsc -yarn run docker-build:app -``` - -Running this will simply generate a Docker container containing the contents of -the UIs `dist` directory. - The backend container can be built by running the following command: ```bash diff --git a/docs/overview/background.md b/docs/overview/background.md index aca46614c2..0c22396e72 100644 --- a/docs/overview/background.md +++ b/docs/overview/background.md @@ -1,8 +1,8 @@ --- id: background title: The Spotify Story -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. +# prettier-ignore +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 diff --git a/docs/overview/roadmap.md b/docs/overview/roadmap.md index b0c031689d..cd319c4ade 100644 --- a/docs/overview/roadmap.md +++ b/docs/overview/roadmap.md @@ -8,9 +8,9 @@ description: Roadmap of Backstage Project > Backstage is currently under rapid development. This means that you can expect > APIs and features to evolve. It is also recommended that teams who adopt -> Backstage today upgrade their installation as new -> [releases](https://github.com/backstage/backstage/releases) become available, -> as Backwards compatibility is not yet guaranteed. +> Backstage today [upgrade their installation](../cli/commands.md#versionsbump) +> as new [releases](https://github.com/backstage/backstage/releases) become +> available, as Backwards compatibility is not yet guaranteed. ## Phases @@ -57,8 +57,10 @@ guidelines to get started. see and manage their services running in K8s, regardless if that's locally, in AWS, GCS, Azure, or elsewhere. -- **Global search** - Extend the basic search functionality currently available - in the Backstage Service Catalog to become a global search experience. +- **[Search platform](../features/search/README.md)** - Evolve the basic search + functionality currently available into a platform that **a)** enables search + across the software catalog, TechDocs, and any other information exposed by + plugins, and **b)** supports a variety of search engine technologies. - **[Software Templates V2](https://github.com/backstage/backstage/issues/2771)** - Expand the templates to make the steps more composable by adding the ability diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index c53193f063..4172e4d1b7 100644 --- a/docs/overview/stability-index.md +++ b/docs/overview/stability-index.md @@ -1,9 +1,8 @@ --- id: stability-index title: Stability Index -description: - An overview of the commitment to stability for different parts of the - Backstage codebase. +# prettier-ignore +description: An overview of the commitment to stability for different parts of the Backstage codebase. --- ## Overview @@ -291,7 +290,7 @@ Stability: `1`. There are plans to rework parts of the Processor interface. ### `catalog-graphql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/catalog-graphql/) -Provides the catalog schema and resolvers for the graphql backend. +Provides the catalog schema and resolvers for the GraphQL backend. Stability: `0`. Under heavy development and subject to change. @@ -385,11 +384,3 @@ A frontend plugin that provides a page where the user can tweak various settings. Stability: `1` - -### `welcome` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/welcome/) - -A plugin that can be used to welcome the user to Backstage. - -Stability: `0`. This used to be the start page for the example app, but has been -replaced by the catalog plugin. It is still viewable at `/welcome` but may be -removed. diff --git a/docs/overview/vision.md b/docs/overview/vision.md index c17e2b17ba..f6d6af90dd 100644 --- a/docs/overview/vision.md +++ b/docs/overview/vision.md @@ -1,8 +1,8 @@ --- id: vision title: Vision -description: Goal is to provide engineers with the best developer experience in -the world +# prettier-ignore +description: Goal is to provide engineers with the best developer experience in the world --- Our goal is to provide engineers with the best developer experience in the diff --git a/docs/overview/what-is-backstage.md b/docs/overview/what-is-backstage.md index b414e62853..ec824a1e11 100644 --- a/docs/overview/what-is-backstage.md +++ b/docs/overview/what-is-backstage.md @@ -1,8 +1,8 @@ --- id: what-is-backstage title: What is Backstage? -description: Backstage is an open platform for building developer portals. -Powered by a centralized service catalog, Backstage restores order to your microservices and infrastructure +# prettier-ignore +description: Backstage is an open platform for building developer portals. Powered by a centralized service catalog, Backstage restores order to your microservices and infrastructure --- ![service-catalog](https://backstage.io/blog/assets/6/header.png) diff --git a/docs/plugins/call-existing-api.md b/docs/plugins/call-existing-api.md index 75e054d2b9..5dbf7988c9 100644 --- a/docs/plugins/call-existing-api.md +++ b/docs/plugins/call-existing-api.md @@ -1,8 +1,8 @@ --- id: call-existing-api title: Call Existing API -description: Describes the various options that Backstage frontend plugins have, -in communicating with service APIs that already exist +# prettier-ignore +description: Describes the various options that Backstage frontend plugins have, in communicating with service APIs that already exist --- This article describes the various options that Backstage frontend plugins have, diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md new file mode 100644 index 0000000000..a496217875 --- /dev/null +++ b/docs/plugins/composability.md @@ -0,0 +1,585 @@ +--- +id: composability +title: Composability System Migration +# prettier-ignore +description: Documentation and migration instructions for new composability APIs. +--- + +## Summary + +This page describes the new composability system that was recently introduced in +Backstage, and it does so from the perspective of the existing patterns and +APIs. As the new system is solidified and existing code is ported, this page +will be removed and replaced with a more direct description of the composability +system. For now, the primary purpose of this documentation is to aid in the +migration of existing plugins, but it does cover the migration of apps as well. + +The core principle of the new composability system is that plugins should have +clear boundaries and connections. It should isolate crashes within a plugin, but +allow navigation between them. It should allow for plugins to be loaded only +when needed, and enable plugins to provide extension points for other plugins to +build upon. The composability system is also built with an app-first mindset, +prioritizing simplicity and clarity in the app over that in the plugins and core +APIs. + +The new composability system isn't a single new API surface. It is a collection +of patterns, primitives, new APIs, and old APIs used in new ways. At the core is +the new concept of extensions, which are exported by plugins for use in the app. +There is also a new primitive called component data, which assists in the +conversion to a more declarative app. The `RouteRef`s now have a clear purpose +as well, and can be used route to pages in a flexible way. + +## New Concepts + +This section is a brief look into all the new and updated concepts that were put +in place to support the new composability system. + +### Component Data + +Component data is a new composability primitive that is introduced as a way to +provide a new data dimension for React components. Data is attached to React +components using a key, and is then readable from any JSX elements created with +those components, using the same key, as illustrated by the following example: + +```tsx +const MyComponent = () =>

This is my component

; +attachComponentData(MyComponent, 'my.data', 5); + +const element = ; +const myData = getComponentData(element, 'my.data'); +// myData === 5 +``` + +The purpose of component data is to provide a method for embedding data that can +be inspected before rendering elements. Element inspection is a pattern that is +quite common among React libraries, and used for example by `react-router` and +`material-ui` to discover properties of the child elements before rendering. +Although in those libraries only the element type and props are typically +inspected, while our component data adds more structured access and simplifies +evolution by allowing for multiple different versions of a piece of data to be +used and interpreted at once. + +The initial use-case for component data is to support route and plugin discovery +through elements in the app. Through this we allow for the React element tree in +the app to be the source of truth, both for which plugins are used, as well as +all top-level plugin routes in the app. The use of component data is not limited +to these use-cases though, as it can be used as a primitive to create new +abstractions as well. + +### Extensions + +Extensions are what plugins export for use in an app. Most typically they are +React components, but in practice they can be any kind of JavaScript value. They +are created using `create*Extension` functions, and wrapped with +`plugin.provide()` in order to create the actual exported extension. + +The extension type is a simple one: + +```ts +export type Extension = { + expose(plugin: BackstagePlugin): T; +}; +``` + +The power of extensions comes from the ability of various actors to hook into +their usage. The creation and plugin wrapping is controlled by whoever owns the +creation function, the Backstage core is able to hook into the process of +exposing the extension outside the plugin, and in the end the app controls the +usage of the extension. + +The Backstage core API currently provides two different types of extension +creators, `createComponentExtension`, and `createRoutableExtension`. Component +extensions are plain React component with no particular requirements, for +example a card for an entity overview page. The component will be exported more +or less as is, but is wrapped to provide things like an error boundary, lazy +loading, and a plugin context. + +Routable extensions build on top of component extensions and are used for any +component that should be rendered at a specific route path, such as top-level +pages or entity page tab content. When creating a routable extension you need to +supply a `RouteRef` as `mountPoint`. The mount point will be the handle of the +component for the outside world, and is used by other components and plugins +that wish to link to the routable component. + +As of now there are only two extension creation functions, but it is possible to +add more of them in the future, both in the core library and in plugins that +wish to provide an extension point for other plugins to build upon. Extensions +are also not tied to React, and can both be used to model generic JavaScript +concepts, as well as potentially bridge to rendering libraries and web +frameworks other than React. + +### Extensions from a Plugin's Point of View + +Extensions are one of the primary methods to traverse the plugin boundary, and +the way that plugins provide concrete content for use within an app. They +replace existing component export concepts such as `Router` or `*Card`s for +display on entity overview pages. + +It is recommended to create the exported extensions either in the top-level +`plugin.ts` file, or in a dedicated `extensions.ts` (or `.tsx`) file. That file +should not contain the bulk of the implementation though, and in fact, if the +extension is a React component it is recommended to lazy-load the actual +component. Component extensions support lazy loading out of the box using the +`lazy` component declaration, for example: + +```ts +export const EntityFooCard = plugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/FooCard').then(m => m.FooCard), + }, + }), +); +``` + +Routable extensions even enforce lazy loading, as it is the only way to provide +a component: + +```ts +export const FooPage = plugin.provide( + createRoutableExtension({ + component: () => import('./components/FooPage').then(m => m.FooPage), + mountPoint: fooPageRouteRef, + }), +); +``` + +### Using Extensions in an App + +Right now all extensions are modelled as React components. The usage of these +extension is like regular usage of any React components, with one important +difference. Extensions must all be part of a single React element tree spanning +from the root `AppProvider`. + +For example, the following app code does **NOT** work: + +```tsx +const AppRoutes = () => ( + + } /> + } /> + +); + +const App = () => ( + + + + + + + +); +``` + +But in this case it is simple to fix! Simply be sure to not create any +intermediate components in the app, for example like this: + +```tsx +const appRoutes = ( + + } /> + } /> + +); + +const App = () => ( + + + {appRoutes} + + +); +``` + +### New Routing System + +A big piece of what is enabled by moving over to this new composability system +is to make `RouteRef`s useful. The `RouteRef`s no longer have their own path, in +fact the only required parameter is currently a `title`. Instead of assigning a +path to each `RouteRef` and possibly overriding these paths in the app, the +concrete `path` for each `RouteRef` is discovered based on the element tree in +the app. Let's consider the following example: + +```tsx +const appRoutes = ( + + } /> + } /> + +); +``` + +We'll assume that `FooPage` and `BarPage` are routable extensions, exported by +the `fooPlugin` and `barPlugin` respectively. Since the `FooPage` is a routable +extension it has a `RouteRef` assigned as its mount point, which we'll refer to +as `fooPageRouteRef`. + +Given the above example, the `fooPageRouteRef` will be associated with the +`'/foo'` route. The path is no longer accessible via the `path` property of the +`RouteRef` though, as the routing structure is tied to the app's react tree. We +instead use the new `useRouteRef` hook if we want to create a concrete link to +the page. The `useRouteRef` hook takes a single `RouteRef` as its only +parameter, and returns a function that is called to create the URL. For example +like this: + +```tsx +const MyComponent = () => { + const fooRoute = useRouteRef(fooPageRouteRef); + return Link to Foo; +}; +``` + +Now let's assume that we want to link from the `BarPage` to the `FooPage`. +Before the introduction of the new composability system, we would do this by +importing the `fooPageRouteRef` exported by the `fooPlugin`. This created an +unnecessary dependency on the plugin, and also provided little flexibility in +allowing the app to tie plugins together, with the links instead being dictated +by the plugins themselves. To solve this, we introduce `ExternalRouteRef`s. Much +like regular route references, they can be passed to `useRouteRef` to create +concrete URLs, but they can not be used as mount points in routable component +and instead have to be associated with a target route using route bindings in +the app. + +We create a new `ExternalRouteRef` inside the `barPlugin`, using a neutral name +that describes its role in the plugin rather than a specific plugin page that it +might be linking to, allowing the app to decide the final target. If the +`BarPage` for example wants to link to an external page in the header, it might +declare an `ExternalRouteRef` similar to this: + +```ts +const headerLinkRouteRef = createExternalRouteRef(); +``` + +### Binding External Routes in the App + +The association of external routes is controlled by the app. Each +`ExternalRouteRef` of a plugin should be bound to an actual `RouteRef`, usually +from another plugin. The binding process happens once at app startup, and is +then used through the lifetime of the app to help resolve concrete route paths. + +Using the above example of the `BarPage` linking to the `FooPage`, we might do +something like this in the app: + +```ts +createApp({ + bindRoutes({ bind }) { + bind(barPlugin.externalRoutes, { + headerLink: fooPlugin.routes.root, + }); + }, +}); +``` + +Given the above binding, using `useRouteRef(headerLinkRouteRef)` within the +`barPlugin` will let us create a link to whatever path the `FooPage` is mounted +at. + +Note that we are not importing and using the `RouteRef`s directly in the app, +and instead rely on the plugin instance to access routes of the plugins. This is +a new convention that was introduced to provide better namespacing and +discoverability of routes, as well as reduce the number of separate exports from +each plugin package. The route references would be supplied to `createPlugin` +like this: + +```ts +// In foo-plugin +export const fooPlugin = createPlugin({ + routes: { + root: fooPageRouteRef, + }, + ... +}) + +// In bar-plugin +export const barPlugin = createPlugin({ + externalRoutes: { + headerLink: headerLinkRouteRef, + }, + ... +}) +``` + +Also note that you almost always want to create the route references themselves +in a different file than the one that creates the plugin instance, for example a +top-level `routes.ts`. This is to avoid circular imports when you use the route +references from other parts of the same plugin. + +### Parameterized Routes + +A new addition to `RouteRef`s is the possibility of adding named and typed +parameters. Parameters are declared at creation, and will enforce presence of +the parameters in the path in the app, and require them as a parameter when +using `useRouteRef`. + +The following is an example of creation and usage of a parameterized route: + +```tsx +// Creation of a parameterized route +const myRouteRef = createRouteRef({ + title: 'My Named Route', + params: ['name'] +}) + +// In the app, where MyPage is a routable extension with myRouteRef set as mountPoint +}/> + +// Usage within a component +const myRoute = useRouteRef(myRouteRef) +return ( +
+ A + B +
+) +``` + +It is currently not possible to have parameterized `ExternalRouteRef`s, or to +bind an external route to a parameterized route, although this may be added in +the future if needed. + +### New Catalog Components + +The established pattern for selecting what plugins should be available on each +catalog page is to use custom components in the app, with logic embedded in the +render function. Typically this takes form as a component that either receives +the entity via props or uses the `useEntity` hook to retrieve the selected +entity. A `switch` or `if` / `else if` chain is then used to select what +children should be rendered based on information in the entity. + +This pattern will no longer work with the new composability system, and in +general is very difficult to build any form of declarative model around, as it +depends on runtime execution. To help replace existing code, a new +`EntitySwitch` component has been added to the `@backstage/catalog` plugin, +which grabs the selected entity from a context, and selects at most one element +to render using a list of `EntitySwitch.Case` children. + +For example, if you want all entities of kind `"Template"` to be rendered with a +`MyTemplate` component, and all other entities to be rendered with a `MyOther` +component, you would do the following: + +```tsx + + + + + + + + + + +// Shorter form if desired: + + }/> + }/> + +``` + +The `EntitySwitch` component will render the children of the first +`EntitySwitch.Case` that returns `true` when the selected entity is passed to +the function of the `if` prop. If none of the cases match, no children will be +rendered, and if a case doesn't specify an `if` filter function, it will always +match. The `if` property is simply a function of the type +`(entity: Entity) => boolean`, for example, `isKind` can be implemented like +this: + +```ts +function isKind(kind: string) { + return (entity: Entity) => entity.kind.toLowerCase() === kind.toLowerCase(); +} +``` + +The `@backstage/catalog` plugin provides a couple of built-in conditions, +`isKind`, `isComponentType`, and `isNamespace`. + +In addition to the `EntitySwitch` component, the catalog plugin also exports a +new `EntityLayout` component. It is a tweaked version and replacement for the +`EntityPageLayout` component, and is introduced more in depth in the app +migration section below. + +## Porting Existing Plugins + +There are a couple of high-level steps to porting an existing plugin to the new +composability system: + +- Remove usage of `router.addRoute` or `router.registerRoute` within + `createPlugin`, and export the page components as routable extensions instead. +- Switch any `Router` export to instead be a routable extension. +- Change any plain component exports, such as catalog overview cards, to be + component extensions. +- Stop exporting `RouteRef`s and instead pass them to `createPlugin`. +- Stop accepting `RouteRef`s as props or importing them from other plugins, + instead create an `ExternalRouteRef` as a replacement, and pass it to + `createPlugin.` +- Rename any other exported symbols according to the naming pattern table below. + +Note that removing the existing exports and configuration is a breaking change +in any plugin. If backwards compatibility is needed the existing code be +deprecated while making the new additions, to then be removed at a later point. + +### Naming Patterns + +Many export naming patterns have been changed to avoid import aliases and to +clarify intent. Refer to the following table to formulate the new name: + +| Description | Existing Pattern | New Pattern | Examples | +| -------------------- | -------------------------- | --------------- | ---------------------------------------------- | +| Top-level Pages | Router | \*Page | CatalogIndexPage, SettingsPage, LighthousePage | +| Entity Tab Content | Router | Entity\*Content | EntityJenkinsContent, EntityKubernetesContent | +| Entity Overview Card | \*Card | Entity\*Card | EntitySentryCard, EntityPagerDutyCard | +| Entity Conditional | isPluginApplicableToEntity | is\*Available | isPagerDutyAvailable, isJenkinsAvailable | +| Plugin Instance | plugin | \*Plugin | jenkinsPlugin, catalogPlugin | + +## Porting Existing Apps + +The first step of porting any app is to replace the root `Routes` component with +`FlatRoutes` from `@backstage/core`. As opposed to the `Routes` component, +`FlatRoutes` only considers the first level of `Route` components in its +children, and provides any additional children to the outlet of the route. It +also removes the need to append `"/*"` to paths, as it is added automatically. + +```diff +const AppRoutes = () => ( +- ++ + ... +- } /> ++ } /> + ... +- ++ +); +``` + +The next step should be to switch from using `EntityPageLayout` to +`EntityLayout`, as this can also be done without waiting for plugins to be +ported. You should also replace the top-level `Router` from the catalog plugin +with the separate `CatalogIndexPage` and `CatalogEntityPage` extensions that +have been added to the catalog: + +```diff +-} +-/> ++} /> ++} ++> ++ ++ +``` + +At that point you should flatten out the element tree as much as possible in the +app, removing any intermediate components. At the top level this should usually +be straightforward, but when reaching the catalog entity pages you may need to +wait for some plugins to be migrated. This is because it is no longer possible +to pass in the selected entity through component props, and it should be picked +up from context inside the plugin instead. See the sections below for how to +carry out migrations of some common entity page patterns. + +Once the app element tree doesn't contain any intermediate components, and all +plugin imports have been switched to extensions rather than plain components, +the app has been fully ported. + +### Switching from EntityPageLayout to EntityLayout + +The existing `EntityPageLayout` is replaced by the new `EntityLayout` component, +which has a slightly different pattern for expressing the contents and paths. + +Porting from the old to the new API is just a matter of moving some things +around. For example, given the following existing code: + +```tsx + + } + /> + } + /> + } + /> + +``` + +It would be ported to this: + +```tsx + + + + + + + + + + + + + +``` + +In addition to the renaming, the `element` prop has been moved to `children`. +Also note that the `/*` suffix has been removed from the `"/kubernetes"` path, +as it's now added automatically. + +Usage of the `EntityLayout` component is required to be able to properly +discover routes, and so it is required to apply this change before you can start +using routable entity content extensions from plugins. + +### Porting Entity Pages + +The established pattern in the app is to use custom components in order to +select what plugin components to render for a given entity. The new +`EntitySwitch` component introduced above is what is intended to replace this +pattern, now that the entire app needs to be rendered as a single element tree. +For example, given the following existing code: + +```tsx +export const EntityPage = () => { + const { entity } = useEntity(); + + switch (entity?.kind?.toLowerCase()) { + case 'component': + return ; + case 'api': + return ; + case 'group': + return ; + case 'user': + return ; + default: + return ; + } +}; +``` + +It would be migrated to this: + +```tsx +export const entityPage = ( + + + + + + + +); +``` + +Note that for example `` has been changed to simply +`componentPage`, that is because just like the `EntityPage` component, the +`ComponentEntityPage` also needs to be ported to be an element rather a +component in a similar way. diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md index bcb86024fd..4ccd48f17e 100644 --- a/docs/plugins/create-a-plugin.md +++ b/docs/plugins/create-a-plugin.md @@ -9,8 +9,9 @@ A Backstage Plugin adds functionality to Backstage. ## Create a Plugin To create a new plugin, make sure you've run `yarn install` and installed -dependencies, then run the following on your command line (invoking the -`backstage-cli`). +dependencies, then run the following on your command line (a shortcut to +invoking the [`backstage-cli create-plugin`](../cli/commands.md#create-plugin)) +from the root of your project. ```bash yarn create-plugin diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md new file mode 100644 index 0000000000..d3b0e36cd9 --- /dev/null +++ b/docs/plugins/github-apps.md @@ -0,0 +1,82 @@ +# Using GitHub Apps for Backend Authentication + +Backstage can be configured to use GitHub Apps for backend authentication. This +comes with advantages such as higher rate limits and that Backstage can act as +an application instead of a user or bot account. + +It also provides a much clearer and better authorization model as a opposed to +the OAuth apps and their respective scopes. + +## Caveats + +- It's not possible to have multiple Backstage GitHub Apps installed in the same + GitHub organization, to be handled by Backstage. We currently don't check + through all the registered GitHub Apps to see which ones are installed for a + particular repository. We only respect global Organization installs right now. +- App permissions is not managed by Backstage. They're created with some simple + default permissions which you are free to change as you need, but you will + need to update them in the GitHub web console, not in Backstage right now. The + permissions that are defaulted are `metadata:read` and `contents:read`. +- The created GitHub App is private by default, this is most likely what you + want for github.com but it's recommended to make your application public for + GitHub Enterprise in order to share application across your GHE organizations. + +A GitHub app created with `backstage-cli create-github-app` will have read +access by default. You have to manually update the GitHub App settings in GitHub +to grant the app more permissions if needed. + +### Using the CLI (public GitHub only) + +You can use the `backstage-cli` to create GitHub App' using a manifest file that +we provide. This gives us a way to automate some of the work required to create +a GitHub app. + +You can read more about the `backstage-cli create-github-app` method +[here](../cli/commands.md#create-github-app) + +Once you've gone through the CLI command, it should produce a `yaml` file in the +root of the project which you can then use as an `include` in your +`app-config.yaml`. You can go ahead and skip to +[here](#including-in-integrations-config) if you've got to this part. + +### GitHub Enterprise + +You have to create the GitHub Application manually using these +[instructions](https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app) +as GitHub Enterprise does not support creation of apps from manifests. + +Once the application is created you have to generate a private key for the +application it in a `yaml` file. + +The yaml file must include the following information. Please note that the +indentation for the `privateKey` is required. + +```yaml +appId: 1 +clientId: client id +clientSecret: client secret +webhookSecret: webhook secret +privateKey: | + -----BEGIN RSA PRIVATE KEY----- + ...Key content... + -----END RSA PRIVATE KEY----- +``` + +### Including in Integrations Config + +Once the credentials are stored in a yaml file generated by `create-github-app` +or manually by following the [GitHub Enterprise](#gitHub-enterprise) +instructions, they can be included in the `app-config.yaml` under the +`integrations` section. + +Please note that the credentials file is highly sensitive and should NOT be +checked into any kind of version control. Instead use your preferred secure +method of distributing secrets. + +```yaml +integrations: + github: + - host: github.com + apps: + - $include: example-backstage-app-credentials.yaml +``` diff --git a/docs/plugins/integrating-plugin-into-service-catalog.md b/docs/plugins/integrating-plugin-into-service-catalog.md index 51d2331140..01de779635 100644 --- a/docs/plugins/integrating-plugin-into-service-catalog.md +++ b/docs/plugins/integrating-plugin-into-service-catalog.md @@ -59,7 +59,7 @@ accordingly inside `Router.tsx`) ### Import and use router in the APP In the `app/src/components/catalog/EntityPage.tsx` (app === your folder, -containing backstage app) import your created Router: +containing Backstage app) import your created Router: ```tsx import { Router as MyPluginRouter } from '@backstage/plugin-my-plugin; diff --git a/docs/plugins/observability.md b/docs/plugins/observability.md new file mode 100644 index 0000000000..8b6668606f --- /dev/null +++ b/docs/plugins/observability.md @@ -0,0 +1,54 @@ +--- +id: observability +title: Observability +# prettier-ignore +description: Adding Observability to Your Plugin +--- + +This article briefly describes the observability options that are available to a +Backstage integrator. + +## Google Analytics + +There is a basic Google Analytics integration built into Backstage. You can +enable it by adding the following to your app configuration: + +```yaml +app: + googleAnalyticsTrackingId: UA-000000-0 +``` + +Replace the tracking ID with your own. + +For more information, learn about Google Analytics +[here](https://marketingplatform.google.com/about/analytics/). + +## Logging + +The backend supplies a central [winston](https://github.com/winstonjs/winston) +root logger that plugins are expected to use for their logging needs. In the +default production setup, it emits structured JSON logs on stdout, with a field +`"service": "backstage"` and also tagged on a per-plugin basis. Plugins that +want to more finely specify what part of their processes that emitted the log +message should add a `"component"` field to do so. + +An example log line could look as follows: + +```json +{ + "service": "backstage", + "type": "plugin", + "plugin": "catalog", + "component": "catalog-all-locations-refresh", + "level": "info", + "message": "Locations Refresh: Refreshing location bootstrap:bootstrap" +} +``` + +## Health Checks + +The example backend in the Backstage repository +[supplies](https://github.com/backstage/backstage/blob/bc18571b7a742863a770b2a54e785d6bbef7e184/packages/backend/src/index.ts#L99) +a very basic health check endpoint on the `/healthcheck` route. You may add such +a handler to your backend as well, and supply your own logic to it that fits +your particular health checking needs. diff --git a/docs/plugins/plugin-development.md b/docs/plugins/plugin-development.md index c5d9763f7d..bfeed58c8f 100644 --- a/docs/plugins/plugin-development.md +++ b/docs/plugins/plugin-development.md @@ -23,44 +23,60 @@ browser APIs or by depending on external modules to do the work. ### Routing -Each plugin is responsible for registering its components to corresponding -routes in the app. +Each plugin can export routable extensions, which are then imported into the app +and mounted at a path. -The app will call the `createPlugin` method on each plugin, passing in a -`router` object with a set of methods on it. +First you will need a `RouteRef` instance to serve as the mount point of your +extensions. This can be used within your own plugin to create a link to the +extension page using `useRouteRef`, as well as for other plugins to link to your +extension. -```jsx +It is best to place these in a separate top-level `src/routes.ts` file, in order +to avoid import cycles, for example like this: + +```tsx +/* src/routes.ts */ +import { createRouteRef } from '@backstage/core'; + +// Note: This route ref is for internal use only, don't export it from the plugin +export const rootRouteRef = createRouteRef({ + title: 'Example Page', +}); +``` + +Now that we have a `RouteRef`, we import it into `src/plugin.ts`, create our +plugin instance with `createPlugin`, as well as create and wrap our routable +extension using `createRoutableExtension` from `@backstage/core`: + +```tsx +/* src/plugin.ts */ import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; -export const rootRouteRef = createRouteRef({ - path: '/new-plugin', - title: 'New plugin', -}); - -export const plugin = createPlugin({ - id: 'new-plugin', - register({ router }) { - router.addRoute(rootRouteRef, ExampleComponent); +// Create a plugin instance and export this from your plugin package +export const examplePlugin = createPlugin({ + id: 'example', + routes: { + root: rootRouteRef, // This is where the route ref should be exported for usage in the app }, }); + +// This creates a routable extension, which are typically full pages of content. +// Each extension should also be exported from your plugin package. +export const ExamplePage = examplePlugin.provide( + createRoutableExtension({ + // The component needs to be lazy-loaded. It's what will actually be rendered in the end. + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + // This binds the extension to this route ref, which allows for routing within and across plugin extensions + mountPoint: rootRouteRef, + }), +); ``` -#### `router` API +This extension can then be imported and used in the app as follow, typically +placed within the top-level ``: -```typescript -addRoute( - target: RouteRef, - Component: ComponentType, - options?: RouteOptions, -): void; - -/** - * @deprecated See the `addRoute` method - */ -registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, -): void; +```tsx +} /> ``` diff --git a/docs/plugins/publishing.md b/docs/plugins/publishing.md index efea02ee23..06fae0533e 100644 --- a/docs/plugins/publishing.md +++ b/docs/plugins/publishing.md @@ -7,7 +7,7 @@ description: Documentation on Publishing npm packages ## npm npm packages are published through CI/CD in the -[.github/workflows/master.yml](https://github.com/backstage/backstage/blob/master/.github/workflows/master.yml) +[`.github/workflows/master.yml`](https://github.com/backstage/backstage/blob/master/.github/workflows/master.yml) workflow. Every commit that is merged to master will be checked for new versions of all public packages, and any new versions will automatically be published to npm. diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index 3f631a9a46..2aacd02cf2 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -28,6 +28,7 @@ new-plugin/ index.ts plugin.test.ts plugin.ts + routes.ts jest.config.js jest.setup.ts package.json @@ -56,26 +57,30 @@ package.json to declare the plugin dependencies, metadata and scripts. In the `src` folder we get to the interesting bits. Check out the `plugin.ts`: ```jsx -import { createPlugin, createRouteRef } from '@backstage/core'; -import ExampleComponent from './components/ExampleComponent'; +import { createPlugin, createRoutableExtension } from '@backstage/core'; -export const rootRouteRef = createRouteRef({ - path: '/new-plugin', - title: 'New plugin', -}); +import { rootRouteRef } from './routes'; -export const plugin = createPlugin({ - id: 'new-plugin', - register({ router }) { - router.addRoute(rootRouteRef, ExampleComponent); +export const examplePlugin = createPlugin({ + id: 'example', + routes: { + root: rootRouteRef, }, }); + +export const ExamplePage = examplePlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + mountPoint: rootRouteRef, + }), +); ``` -This is where the plugin is created and where it hooks into the app by declaring -what component should be shown on what URL. See reference docs for -[createPlugin](../reference/createPlugin.md) or -[router](../reference/createPlugin-router.md). +This is where the plugin is created and where it creates and exports extensions +that can be imported and used the app. See reference docs for +[createPlugin](../reference/createPlugin.md) or introduction to the new +[Composability System](./composability.md). ## Components @@ -91,12 +96,15 @@ You may tweak these components, rename them and/or replace them completely. ## Connecting the plugin to the Backstage app -There are two things needed for a Backstage app to start making use of a plugin. +There are three things needed for a Backstage app to start making use of a +plugin. 1. Add plugin as dependency in `app/package.json` 2. `import` plugin in `app/src/plugins.ts` +3. Import and use one or more plugin extensions, for example in + `app/src/App.tsx`. -Luckily these two steps happen automatically when you create a plugin with the +Luckily these three steps happen automatically when you create a plugin with the Backstage CLI. ## Talking to the outside world diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index 622c085291..5bfb4fe096 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -14,7 +14,7 @@ Here's a code sample: import { createPlugin } from '@backstage/core'; export default createPlugin({ - id: 'welcome', + id: 'plugin-name', register({ featureFlags }) { featureFlags.register('enable-example-feature'); }, diff --git a/docs/reference/createPlugin-router.md b/docs/reference/createPlugin-router.md deleted file mode 100644 index 89ee44e558..0000000000 --- a/docs/reference/createPlugin-router.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: createPlugin-router -title: createPlugin - router -description: Documentation on createPlugin - router ---- - -The router that is passed to the `register` function makes it possible for -plugins to hook into routing of the Backstage app and provide the end users with -new views to navigate to. This is done by utilising the following methods on the -`router`: - -```typescript -addRoute( - target: RouteRef, - Component: ComponentType, - options?: RouteOptions, -): void; - -/** - * @deprecated See the `addRoute` method - */ -registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, -): void; -``` - -## RouteRef - -`addRoute` method is using mutable RouteRefs, which can be created as following: - -```ts -import { createRouteRef } from '@backstage/core'; - -const myPluginRouteRef = createRouteRef({ - path: '/my-plugin', - title: 'My Plugin', -}); -``` diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md index 4a58a5ecdc..e3df66fa12 100644 --- a/docs/reference/createPlugin.md +++ b/docs/reference/createPlugin.md @@ -17,32 +17,24 @@ type PluginConfig = { }; type PluginHooks = { - router: RouterHooks; + featureFlags: FeatureFlagsHooks; }; ``` -- [Read more about the router here](createPlugin-router.md) - [Read more about feature flags here](createPlugin-feature-flags.md) ## Example Uses ### Creating a basic plugin -Showcasing adding a route and a feature flag. +Showcasing adding a feature flag. ```jsx -import { createPlugin, createRouteRef } from '@backstage/core'; -import ExampleComponent from './components/ExampleComponent'; - -export const rootRouteRef = createRouteRef({ - path: '/new-plugin', - title: 'New Plugin', -}); +import { createPlugin } from '@backstage/core'; export default createPlugin({ id: 'new-plugin', register({ router, featureFlags }) { - router.addRoute(rootRouteRef, ExampleComponent); featureFlags.register('enable-example-component'); }, }); diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md index dafdb7d55f..69fe5d26ba 100644 --- a/docs/reference/utility-apis/BackstageIdentityApi.md +++ b/docs/reference/utility-apis/BackstageIdentityApi.md @@ -79,7 +79,7 @@ Referenced by: [getBackstageIdentity](#getbackstageidentity).
 export type BackstageIdentity = {
   /**
-   * The backstage user ID.
+   * The Backstage user ID.
    */
   id: string;
 
diff --git a/docs/reference/utility-apis/ErrorApi.md b/docs/reference/utility-apis/ErrorApi.md
index 93f4f9cd48..9bba0c76c6 100644
--- a/docs/reference/utility-apis/ErrorApi.md
+++ b/docs/reference/utility-apis/ErrorApi.md
@@ -29,7 +29,7 @@ These types are part of the API declaration, but may not be unique to this API.
 
 ### Error
 
-Mirrors the javascript Error class, for the purpose of providing documentation
+Mirrors the JavaScript Error class, for the purpose of providing documentation
 and optional fields.
 
 
diff --git a/docs/support/project-structure.md b/docs/support/project-structure.md
index a30288e85e..5c8a8cd3bb 100644
--- a/docs/support/project-structure.md
+++ b/docs/support/project-structure.md
@@ -1,8 +1,8 @@
 ---
 id: project-structure
 title: Backstage Project Structure
-description:
-  Introduction to files and folders in the Backstage Project repository
+# prettier-ignore
+description: Introduction to files and folders in the Backstage Project repository
 ---
 
 Backstage is a complex project, and the GitHub repository contains many
@@ -32,10 +32,6 @@ the code.
   better control over our `yarn.lock` file and hopefully avoid problems due to
   yarn versioning differences.
 
-- [`docker/`](https://github.com/backstage/backstage/tree/master/docker) - Files
-  related to our root Dockerfile. We are planning to refactor this, so expect
-  this folder to be moved in the future.
-
 - [`contrib/`](https://github.com/backstage/backstage/tree/master/contrib) -
   Collection of examples or resources provided by the community. We really
   appreciate contributions in here and encourage them being kept up to date.
diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md
index 1dcca9d7e0..d322e2ec3c 100644
--- a/docs/tutorials/quickstart-app-auth.md
+++ b/docs/tutorials/quickstart-app-auth.md
@@ -3,20 +3,18 @@ id: quickstart-app-auth
 title: Monorepo App Setup With Authentication
 ---
 
-###### September 15th 2020 - @backstage/create-app - v0.1.1-alpha.21
+###### January 8th 2021 - @backstage/create-app - v0.4.5
 
 
> This document takes you through setting up a Backstage app that runs in your > own environment. It starts with a skeleton install and verifying of the -> monorepo's functionality. Next, GitHub authentication is added and tested. +> monorepo's functionality. Next, authentication is added and tested. > -> This document assumes you have Node.js 12 active along with Yarn and Python. -> Please note, that at the time of this writing, the current version is -> 0.1.1-alpha.21. This guide can still be used with future versions, just, -> verify as you go. If you run into issues, you can compare your setup with mine -> here > -> [simple-backstage-app](https://github.com/johnson-jesse/simple-backstage-app). +> This document assumes you have Node.js 12 or 14 active along with Yarn and +> Python. Please note, that at the time of this writing, the current version is +> v0.4.5. This guide can still be used with future versions, just, verify as you +> go. # The Skeleton Application @@ -55,7 +53,17 @@ guest. Let's fix that now and add auth. # The Auth Configuration -1. Open `app-config.yaml` and change it as follows +A default Backstage installation includes multiple authentication providers out +of the box. The steps to enable new authentication providers in Backstage are +very similar to each other, the biggest difference is usually configuring the +external authentication provider. Please see a subset of possible providers and +instructions to integrate them below. Steps 1 & 2 are described separately for +each provider and steps beyond that are common for all. + +
GitHub +

+ +### 1. Open `app-config.yaml` and change it as follows _from:_ @@ -75,23 +83,229 @@ auth: $env: AUTH_GITHUB_CLIENT_ID clientSecret: $env: AUTH_GITHUB_CLIENT_SECRET - ## uncomment the following three lines if using enterprise + ## uncomment the following two lines if using enterprise # enterpriseInstanceUrl: # $env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL ``` -2. Set environment variables in whatever fashion is easiest for you. I chose to - add mine to my `.zshrc` profile. +### 2. Generate a GitHub client ID and secret + +- 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/api/auth/github` +- Click [Register application] +- On the next page, copy and paste your new Client ID and Client Secret to + environment variables defined in the `app-config.yaml` file, + `AUTH_GITHUB_CLIENT_ID` & `AUTH_GITHUB_CLIENT_SECRET` + +

+
+ +
GitLab +

+ +### 1. Open `app-config.yaml` and change it as follows + +_from:_ + +```yaml +auth: + providers: {} +``` + +_to:_ + +```yaml +auth: + providers: + gitlab: + development: + clientId: + $env: AUTH_GITLAB_CLIENT_ID + clientSecret: + $env: AUTH_GITLAB_CLIENT_SECRET + audience: https://gitlab.com # Or your self-hosted GitLab instance URL +``` + +### 2. Generate a GitLab Application client ID and secret + +- Log into GitLab +- Navigate to (Profile > Settings > + Applications)[https://gitlab.com/-/profile/applications] +- Name your application +- Set Callback URL = `http://localhost:7000/api/auth/gitlab/handler/frame` +- Select the following values: + - `read_user` (Read the authenticated user's personal information) + - `read_repository` (Allows read-only access to the repository) + - `write_repository` (Allows read-write access to the repository) + - `openid` (Authenticate using OpenID Connect) + - `profile` (Allows read-only access to the user's personal information using + OpenID Connect) + - `email` (Allows read-only access to the user's primary email address using + OpenID Connect) +- Click [Save application] +- On the next page, copy and paste your new Application ID and Secret to + environment variables defined in the `app-config.yaml` file, + `AUTH_GITLAB_CLIENT_ID` & `AUTH_GITLAB_CLIENT_SECRET` + +

+
+ +
Google +

+ +### 1. Open `app-config.yaml` and change it as follows + +_from:_ + +```yaml +auth: + providers: {} +``` + +_to:_ + +```yaml +auth: + providers: + google: + development: + clientId: + $env: AUTH_GOOGLE_CLIENT_ID + clientSecret: + $env: AUTH_GOOGLE_CLIENT_SECRET +``` + +### 2. Generate Google Credentials in Google Cloud console + +- Log into https://console.cloud.google.com +- Select or create a new project from the dropdown on the top bar +- Navigate to (APIs & Services > + Credentials)[https://console.cloud.google.com/apis/credentials] +- Click Create Credentials and select [OAuth client ID] +- Select Web Application as the application type +- Add new Authorised JavaScript origin = `http://localhost:3000` +- Add new Authorised redirect URI = + `http://localhost:7000/api/auth/google/handler/frame` +- Click [Save application] +- Google should display a modal with your Client ID and Secret. Copy and paste + those to environment variables defined in the `app-config.yaml` file, + `AUTH_GOOGLE_CLIENT_ID` & `AUTH_GOOGLE_CLIENT_SECRET` + +

+
+ +
Microsoft +

+ +### 1. Open `app-config.yaml` and change it as follows + +_from:_ + +```yaml +auth: + providers: {} +``` + +_to:_ + +```yaml +auth: + providers: + microsoft: + development: + clientId: + $env: AUTH_MICROSOFT_CLIENT_ID + clientSecret: + $env: AUTH_MICROSOFT_CLIENT_SECRET + tenantId: + $env: AUTH_MICROSOFT_TENANT_ID +``` + +### 2. Create a Microsoft App Registration in Microsoft Portal + +- Log into https://portal.azure.com +- Navigate to (Azure Active Directory > App + Registrations)[https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps] +- Create a New Registration +- Add new Redirect URI = `http://localhost:3000` +- Add new Authorised redirect URI = + `http://localhost:7000/api/auth/microsoft/handler/frame` +- Click [Save application] +- Set environment variable `AUTH_MICROSOFT_CLIENT_ID` from + `Application (client) Id` displayed on the directory page +- Set environment variable `AUTH_MICROSOFT_TENANT_ID` from + `Directory (tenant) ID` displayed on the directory page +- Navigate to Certificates & Secrets section and click [Create a new secret] +- Set environment variable `AUTH_MICROSOFT_CLIENT_SECRET` from the `value` field + created. + +

+
+ +
Auth0 +

+ +### 1. Open `app-config.yaml` and change it as follows + +_from:_ + +```yaml +auth: + providers: {} +``` + +_to:_ + +```yaml +auth: + providers: + auth0: + development: + clientId: + $env: AUTH_AUTH0_CLIENT_ID + clientSecret: + $env: AUTH_AUTH0_CLIENT_SECRET + domain: + $env: AUTH_AUTH0_DOMAIN_ID +``` + +### 2. Create an Auth0 application in the Auth0 management console + +- Log into https://manage.auth0.com/dashboard/ +- Navigate to Applications +- Create a New Application + - Select Single Page Web Application +- Go to Settings tab +- Add new line to Allowed Callback URLs = + `http://localhost:7000/api/auth/auth0/handler/frame` +- Click [Save Changes] +- Set environment variables displayed on the Basic Information page + - `AUTH_AUTH0_CLIENT_ID` from `Client ID` displayed on Auth0 application page + - `AUTH_AUTH0_CLIENT_SECRET` from `Client Secret` displayed on Auth0 + application page + - `AUTH_AUTH0_DOMAIN_ID` from `Domain` displayed on Auth0 application page + +

+
+ +### 3. Set environment variables in whatever fashion is easiest for you. I chose to + +add mine to my `.zshrc` profile. ```zsh # For macOS Catalina & Z Shell # ------ simple-backstage-app GitHub +# +# (Change the name of the environment variables based on your auth setup above) export AUTH_GITHUB_CLIENT_ID=xxx export AUTH_GITHUB_CLIENT_SECRET=xxx # export AUTH_GITHUB_ENTERPRISE_INSTANCE_URL=https://github.{MY_BIZ}.com ``` -3. And of course I need to source that file. +### 4. And of course I need to source that file. ```zsh # Loading the new variables @@ -107,58 +321,62 @@ export AUTH_GITHUB_CLIENT_SECRET=xxx > ... ``` -4. The values to replace `xxx` above come from your oauth app setup. +### 5. Open and change _root > packages > app > src >_ `App.tsx` to use correct -``` -> 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/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. -``` - -5. Open and change _root > packages > app > src >_`App.tsx` as follows +authentication provider reference ```tsx -// Add the following imports to the existing list from core import { githubAuthApiRef, SignInPage } from '@backstage/core'; ``` -6. In the same file, change the createApp function as follows +Modify the imported reference based on the authentication method you selected +above: + +| Auth Provider | Import Name | +| ------------- | ------------------- | +| GitHub | githubAuthApiRef | +| GitLab | gitlabAuthApiRef | +| Google | googleAuthApiRef | +| Microsoft | microsoftAuthApiRef | +| Auth0 | auth0AuthApiRef | + +### 6. In the same file, modify createApp + +Remember to modify the provider information based on the table above. ```tsx const app = createApp({ apis, plugins: Object.values(plugins), components: { - SignInPage: props => { - return ( - - ); - }, + SignInPage: props => ( + + ), }, }); ``` -7. Start the backend and frontend as before +After finishing setting up one (or multiple) authentication providers defined +above you can 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. +For more information you can clone +[the backstage-auth-example repository](https://github.com/RoadieHQ/backstage-auth-example). +Each authentication setting is set up there on a branch named after the +authentication provider. + # Where to go from here > You're probably eager to write your first custom plugin. Follow this next diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 0f8d9bb84b..6208fbe30d 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -59,7 +59,7 @@ import GitHubIcon from '@material-ui/icons/GitHub'; ``` Simple! The App will reload with your changes automatically. You should now see -a github icon displayed in the sidebar. Clicking that will link to our new +a GitHub icon displayed in the sidebar. Clicking that will link to our new plugin. And now, the API fun begins. # The Identity @@ -72,8 +72,7 @@ Our first modification will be to extract information from the Identity API. ```tsx // Add identityApiRef to the list of imported from core -import { identityApiRef } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; +import { identityApiRef, useApi } from '@backstage/core'; ``` 3. Adjust the ExampleComponent from inline to block @@ -143,8 +142,8 @@ import { TableColumn, Progress, githubAuthApiRef, + useApi, } from '@backstage/core'; -import { useApi } from '@backstage/core-api'; import { graphql } from '@octokit/graphql'; const ExampleFetchComponent = () => { diff --git a/microsite/blog/2020-09-08-announcing-tech-docs.md b/microsite/blog/2020-09-08-announcing-tech-docs.md index f09c73fd83..98fbad57c5 100644 --- a/microsite/blog/2020-09-08-announcing-tech-docs.md +++ b/microsite/blog/2020-09-08-announcing-tech-docs.md @@ -6,7 +6,7 @@ authorURL: https://github.com/garyniemen Since we [open sourced Backstage](https://backstage.io/blog/2020/03/16/announcing-backstage), one of the most requested features has been for a technical documentation plugin. Well, good news. The first open source version of TechDocs is here. Now let’s start collaborating and making it better, together. - diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md index c7ae9c492d..d265698694 100644 --- a/microsite/blog/2020-10-22-cost-insights-plugin.md +++ b/microsite/blog/2020-10-22-cost-insights-plugin.md @@ -6,7 +6,7 @@ authorURL: https://twitter.com/janisa_a How did Spotify save millions on cloud costs within a matter of months?? We made cost optimization just another part of the daily development process. Our newly open sourced [Cost Insights plugin](https://github.com/backstage/backstage/tree/master/plugins/cost-insights) makes a team’s cloud costs visible — and actionable — right inside Backstage. So engineers can see the impact of their cloud usage (down to a product and resource level) and make optimizations wherever and whenever it makes sense. By managing cloud costs from the ground up, you can make smarter decisions that let you continue to build and scale quickly, without wasting resources. - + Are we turning engineers into accountants? Nope, we’re just letting engineers do what they do best, in the place that feels natural to them: inside Backstage. diff --git a/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md new file mode 100644 index 0000000000..a0d11a580f --- /dev/null +++ b/microsite/blog/2021-01-12-new-backstage-feature-kubernetes-for-service-owners.md @@ -0,0 +1,72 @@ +--- +title: New Backstage feature: Kubernetes for Service owners +author: Matthew Clarke, Spotify +authorURL: https://github.com/mclarke47 +--- + +![Animation of Kubernetes and cloud provider icons becoming the Backstage logo](assets/21-01-12/backstage-k8s-1-hero.gif) + +TLDR; We’re rethinking the Kubernetes developer experience with a new feature: a Kubernetes monitoring tool that’s designed around the needs of service owners, not cluster admins. Now developers can easily check the health of their services no matter how or where those services are deployed — whether it’s on a local host for testing or in production on dozens of clusters around the world. + +And since Backstage uses the native Kubernetes API, the feature works with whichever cloud provider (AWS, Azure, GCP, etc.) or managed service (OpenShift, IBM Cloud, GKE, etc.) you already use. + + + +## The missing link between K8s and your service + +A core feature of Backstage is its service catalog, which aggregates information about software systems together inside a single tool, with a consistent, familiar UI. + +By navigating to a service’s overview page in Backstage, you can see everything you need to know about the service: what it does, its APIs and technical documentation, CI/CD progress — and now detailed information about its presence on Kubernetes clusters. + +## No more context switching + +Kubernetes in Backstage can be configured to search multiple clusters for your services. It will then aggregate them together into a single view. So if you deploy to multiple clusters you will no longer need to switch kubectl contexts to understand the current state of your service. + +![List of deployments in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-2-deployments.png) + +## Automatic error reporting + +Instead of trying different kubectl commands to figure out where an error occurred, Backstage will automatically find and highlight errors in Kubernetes resources that are affecting your service. So you can spend time fixing errors, not hunting for them. + +![Error reporting screen in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-3-error-reporting.png) + +## Autoscaling limits at a glance + +Backstage also shows you how close your service is to its autoscaling limit. Coming up to a period of high load? Now you will be able to see how your horizontal autoscaling is dealing with it across multiple clusters. + +![Autoscaling limits screen in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-4-autoscaling-limits.png) + +![Autoscaling limits screen in Backstage Kubernetes plugin](assets/21-01-12/backstage-k8s-5-autoscaling-limits.png) + +## Pick a cloud, any Cloud + +Since Backstage communicates directly with the Kubernetes API, it’s cloud agnostic — it doesn’t matter how or where you’re running Kubernetes. You’ll always get the same familiar view of your deployments, whether you’re: + +- Deploying to clusters on AWS, Azure, GCP, or another cloud provider +- Using an unmanaged or managed Kubernetes service (like OpenShift, etc.) +- Migrating from one cloud provider or service to another +- Testing on a single local machine or deploying to a dozen clusters in production + +In short: local or global, single or multi-cloud, managed or unmanaged — Backstage always provides a seamless Kubernetes experience for your service owners’ day-to-day development needs. + +## Rethinking the developer experience + +The philosophy behind Backstage is simple: improve developer experience by reducing infrastructure complexity. As popular and widespread as Kubernetes has become, all of the tools to date have been geared toward the needs of cluster admins. These tools add unnecessary complexity to the workflows of the typical developer building, testing, and deploying services. + +We believe Backstage Kubernetes gives developers back control of their services by providing a more focused and consistent experience. Backstage provides a single standard for developers to monitor their Kubernetes deployments, regardless of the underlying cloud infrastructure. + +## Future iterations + +The current focus of Kubernetes in Backstage is Deployments/ReplicaSets/Pods — but we know that not everyone utilizes these. + +As we continue to grow and develop Kubernetes in Backstage with the community, we hope to offer support for Kubernetes resources beyond Deployments and Custom Resource Definitions. You can browse or add open issues for the plugin [here]. + +## Getting started + +We made the Kubernetes plugin a core feature of Backstage. Like Software Templates (scaffolder) and TechDocs, the k8s-plugin is installed with the core app. When you update the app to the latest version and go to the Kubernetes tab of any service, you will be asked to provide your cloud provider credentials. To learn more, including details on configuration and surfacing your Kubernetes components as part of an entity, [read the docs]. + +To contribute or get more information on Kubernetes in Backstage, [join the discussion on Discord]! + +[here]: https://github.com/backstage/backstage/issues?q=is%3Aissue+is%3Aopen+kubernetes+label%3Ak8s-plugin +[read the docs]: https://backstage.io/docs/features/kubernetes/overview +[join the discussion on discord]: https://discord.gg/MUpMjP2 diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-1-hero.gif b/microsite/blog/assets/21-01-12/backstage-k8s-1-hero.gif new file mode 100644 index 0000000000..7af1a5a305 Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-1-hero.gif differ diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-2-deployments.png b/microsite/blog/assets/21-01-12/backstage-k8s-2-deployments.png new file mode 100644 index 0000000000..4a9f2b06fa Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-2-deployments.png differ diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-3-error-reporting.png b/microsite/blog/assets/21-01-12/backstage-k8s-3-error-reporting.png new file mode 100644 index 0000000000..29002369d3 Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-3-error-reporting.png differ diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-4-autoscaling-limits.png b/microsite/blog/assets/21-01-12/backstage-k8s-4-autoscaling-limits.png new file mode 100644 index 0000000000..7333fc7076 Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-4-autoscaling-limits.png differ diff --git a/microsite/blog/assets/21-01-12/backstage-k8s-5-autoscaling-limits.png b/microsite/blog/assets/21-01-12/backstage-k8s-5-autoscaling-limits.png new file mode 100644 index 0000000000..c9be936787 Binary files /dev/null and b/microsite/blog/assets/21-01-12/backstage-k8s-5-autoscaling-limits.png differ diff --git a/microsite/core/Components.js b/microsite/core/Components.js index 49e7d7daca..4811b49291 100644 --- a/microsite/core/Components.js +++ b/microsite/core/Components.js @@ -106,9 +106,29 @@ const Breakpoint = ({ narrow, wide }) => ( ); +const Banner = simpleComponent('div', 'Banner', ['hidden']); +Banner.Container = simpleComponent('div', 'Banner__Container'); + +const BannerDismissButton = simpleComponent( + props => ( + + + + ), + 'Banner__DismissButton', +); + +Banner.Dismissable = ({ storageKey, children }) => ( + +); + module.exports = { Block, ActionBlock, Breakpoint, BulletLine, + Banner, }; diff --git a/microsite/data/plugins/argo-cd.yaml b/microsite/data/plugins/argo-cd.yaml index a47fa08d82..ca753f682a 100644 --- a/microsite/data/plugins/argo-cd.yaml +++ b/microsite/data/plugins/argo-cd.yaml @@ -2,7 +2,7 @@ title: Argo CD author: roadie.io authorUrl: https://roadie.io -category: CI +category: CI/CD description: View Argo CD status for your projects in Backstage. documentation: https://roadie.io/backstage/plugins/argo-cd iconUrl: https://roadie.io/images/logos/argo.png diff --git a/microsite/data/plugins/buildkite.yaml b/microsite/data/plugins/buildkite.yaml index eeb9440aac..16c6960898 100644 --- a/microsite/data/plugins/buildkite.yaml +++ b/microsite/data/plugins/buildkite.yaml @@ -2,7 +2,7 @@ title: Buildkite author: roadie.io authorUrl: https://roadie.io -category: CI +category: CI/CD description: View Buildkite CI builds for your service in Backstage. documentation: https://roadie.io/backstage/plugins/buildkite iconUrl: https://roadie.io/images/logos/buildkite.png diff --git a/microsite/data/plugins/circleci.yaml b/microsite/data/plugins/circleci.yaml index 467da9fb1e..0acad832fa 100644 --- a/microsite/data/plugins/circleci.yaml +++ b/microsite/data/plugins/circleci.yaml @@ -2,7 +2,7 @@ title: CircleCI author: Spotify authorUrl: https://github.com/spotify -category: CI +category: CI/CD description: Automate your development process with CI hosted in the cloud or on a private server. documentation: https://github.com/backstage/backstage/tree/master/plugins/circleci iconUrl: https://www.saaves.com/storage/brochure/logo-circleci-icon1583764538.png diff --git a/microsite/data/plugins/cloud-build.yaml b/microsite/data/plugins/cloud-build.yaml index 2b40d02de2..253a935392 100644 --- a/microsite/data/plugins/cloud-build.yaml +++ b/microsite/data/plugins/cloud-build.yaml @@ -2,7 +2,7 @@ title: Google Cloud Build author: Trivago authorUrl: https://www.trivago.com -category: CI +category: CI/CD description: Build, test, and deploy on Google's serverless CI/CD platform. documentation: https://github.com/backstage/backstage/tree/master/plugins/cloudbuild iconUrl: https://avatars2.githubusercontent.com/u/38220399?s=400&v=4 diff --git a/microsite/data/plugins/gcp-projects.yaml b/microsite/data/plugins/gcp-projects.yaml index 1c015821b2..7c4b75d2a4 100644 --- a/microsite/data/plugins/gcp-projects.yaml +++ b/microsite/data/plugins/gcp-projects.yaml @@ -2,7 +2,7 @@ title: GCP Project Creator author: Trivago authorUrl: https://www.trivago.com -category: Cloud +category: Infrastructure description: Create, list and manage your Google Cloud Projects. documentation: https://github.com/backstage/backstage/tree/master/plugins/gcp-projects iconUrl: https://avatars1.githubusercontent.com/u/2810941?s=280&v=4 diff --git a/microsite/data/plugins/github-actions.yaml b/microsite/data/plugins/github-actions.yaml index aeccdc9b86..8d5a74d9ce 100644 --- a/microsite/data/plugins/github-actions.yaml +++ b/microsite/data/plugins/github-actions.yaml @@ -2,7 +2,7 @@ title: GitHub Actions author: Spotify authorUrl: https://github.com/spotify -category: CI +category: CI/CD description: GitHub Actions makes it easy to automate all your software workflows, now with world-class CI/CD. Build, test, and deploy your code right from GitHub. documentation: https://github.com/backstage/backstage/tree/master/plugins/github-actions iconUrl: https://avatars2.githubusercontent.com/u/44036562?s=400&v=4 diff --git a/microsite/data/plugins/github-insights.yaml b/microsite/data/plugins/github-insights.yaml index 1dba3dcfd1..f127f88a3c 100644 --- a/microsite/data/plugins/github-insights.yaml +++ b/microsite/data/plugins/github-insights.yaml @@ -2,7 +2,7 @@ title: GitHub Insights author: roadie.io authorUrl: https://roadie.io -category: Monitoring +category: Source Control Mgmt description: View GitHub Insights for your components in Backstage. documentation: https://roadie.io/backstage/plugins/github-insights iconUrl: https://roadie.io/images/logos/insights.png diff --git a/microsite/data/plugins/github-pull-requests.yaml b/microsite/data/plugins/github-pull-requests.yaml index 8e44a4bf56..4172cdff6b 100644 --- a/microsite/data/plugins/github-pull-requests.yaml +++ b/microsite/data/plugins/github-pull-requests.yaml @@ -2,7 +2,7 @@ title: GitHub Pull Requests author: roadie.io authorUrl: https://roadie.io/ -category: CI +category: Source Control Mgmt description: View GitHub pull requests for your service in Backstage. documentation: https://roadie.io/backstage/plugins/github-pull-requests iconUrl: https://roadie.io/images/logos/github.png diff --git a/microsite/data/plugins/jenkins.yaml b/microsite/data/plugins/jenkins.yaml index 638758d54d..7a8f884221 100644 --- a/microsite/data/plugins/jenkins.yaml +++ b/microsite/data/plugins/jenkins.yaml @@ -2,7 +2,7 @@ title: Jenkins author: '@timja' authorUrl: https://github.com/timja -category: CI +category: CI/CD description: Jenkins offers a simple way to set up a continuous integration and continuous delivery environment. documentation: https://github.com/backstage/backstage/tree/master/plugins/jenkins iconUrl: https://img.icons8.com/color/1600/jenkins.png diff --git a/microsite/data/plugins/jira.yaml b/microsite/data/plugins/jira.yaml index 579243d11c..c32455c7d5 100644 --- a/microsite/data/plugins/jira.yaml +++ b/microsite/data/plugins/jira.yaml @@ -2,7 +2,7 @@ title: Jira author: roadie.io authorUrl: https://roadie.io -category: Project Management +category: Agile Planning description: View Jira summary for your projects in Backstage. documentation: https://roadie.io/backstage/plugins/jira iconUrl: https://roadie.io/images/logos/jira.png diff --git a/microsite/data/plugins/kafka.yaml b/microsite/data/plugins/kafka.yaml new file mode 100644 index 0000000000..47d354b44f --- /dev/null +++ b/microsite/data/plugins/kafka.yaml @@ -0,0 +1,11 @@ +--- +title: Kafka +author: '@nirga' +authorUrl: https://github.com/nirga +category: Monitoring +description: Observability for Apache Kafka clusters and async API of components. +documentation: https://github.com/backstage/backstage/tree/master/plugins/kafka +iconUrl: https://kafka.apache.org/images/apache-kafka.png +npmPackageName: '@backstage/plugin-kafka' +tags: + - monitoring diff --git a/microsite/data/plugins/lighthouse.yaml b/microsite/data/plugins/lighthouse.yaml index 88d444e0ef..be70dfd74a 100644 --- a/microsite/data/plugins/lighthouse.yaml +++ b/microsite/data/plugins/lighthouse.yaml @@ -2,7 +2,7 @@ title: Lighthouse author: Spotify authorUrl: https://github.com/spotify -category: Accessibility +category: Quality description: Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website. documentation: https://github.com/backstage/backstage/tree/master/plugins/lighthouse iconUrl: https://seeklogo.com/images/G/google-lighthouse-logo-1C7FA08580-seeklogo.com.png diff --git a/microsite/data/plugins/opsgenie.yaml b/microsite/data/plugins/opsgenie.yaml new file mode 100644 index 0000000000..43ba040f75 --- /dev/null +++ b/microsite/data/plugins/opsgenie.yaml @@ -0,0 +1,13 @@ +--- +title: Opsgenie +author: K-Phoen +authorUrl: https://github.com/K-Phoen +category: Monitoring +description: Opsgenie offers a simple way to associate alerts to components and vizualize incidents. +documentation: https://github.com/K-Phoen/backstage-plugin-opsgenie/ +iconUrl: https://avatars.githubusercontent.com/u/1818843?s=200&v=4 +npmPackageName: '@k-phoen/backstage-plugin-opsgenie' +tags: + - monitoring + - errors + - alerting diff --git a/microsite/data/plugins/snyk-security.yaml b/microsite/data/plugins/snyk-security.yaml new file mode 100644 index 0000000000..35f00595d1 --- /dev/null +++ b/microsite/data/plugins/snyk-security.yaml @@ -0,0 +1,9 @@ +--- +title: Snyk +author: Snyk Tech Services +authorUrl: https://snyk.io +category: Security +description: View Snyk scanned vulnerabilities and license compliance of your components directly in Backstage. +documentation: https://github.com/snyk-tech-services/backstage-plugin-snyk/blob/main/README.md +iconUrl: https://storage.googleapis.com/snyk-technical-services.appspot.com/snyk-logo-vertical-black.png +npmPackageName: 'backstage-plugin-snyk' diff --git a/microsite/data/plugins/travis-ci.yaml b/microsite/data/plugins/travis-ci.yaml index fe1b18c9d4..a6b8c6ebd7 100644 --- a/microsite/data/plugins/travis-ci.yaml +++ b/microsite/data/plugins/travis-ci.yaml @@ -2,7 +2,7 @@ title: Travis CI author: roadie.io authorUrl: https://roadie.io/ -category: CI +category: CI/CD description: View Travis CI builds for your service in Backstage. documentation: https://roadie.io/backstage/plugins/travis-ci iconUrl: https://roadie.io/images/logos/travis.png diff --git a/microsite/package.json b/microsite/package.json index 975bec3149..cb82e6ec85 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@spotify/prettier-config": "^9.0.0", - "docusaurus": "^2.0.0-alpha.378053ac5", + "docusaurus": "^2.0.0-alpha.70", "js-yaml": "^4.0.0", "prettier": "^2.2.1" }, diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js index c26fc9a8e8..47ea1ee9ee 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -100,9 +100,9 @@ const Background = props => { width="560" height="315" src="https://www.youtube.com/embed/YLAd5hdXR_Q" - frameborder="0" + frameBorder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" - allowfullscreen + allowFullScreen > @@ -135,9 +135,9 @@ const Background = props => { width="560" height="315" src="https://www.youtube.com/embed/mOLCgdPw1iA" - frameborder="0" + frameBorder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" - allowfullscreen + allowFullScreen > diff --git a/microsite/pages/en/docs/features/software-catalog/index.js b/microsite/pages/en/docs/features/software-catalog/index.js new file mode 100644 index 0000000000..cffc91af21 --- /dev/null +++ b/microsite/pages/en/docs/features/software-catalog/index.js @@ -0,0 +1,15 @@ +const React = require('react'); +const Redirect = require('../../../../../core/Redirect.js'); + +const siteConfig = require(process.cwd() + '/siteConfig.js'); + +function Docs() { + return ( + + ); +} + +module.exports = Docs; diff --git a/microsite/pages/en/docs/features/software-templates/index.js b/microsite/pages/en/docs/features/software-templates/index.js new file mode 100644 index 0000000000..79d3f0659e --- /dev/null +++ b/microsite/pages/en/docs/features/software-templates/index.js @@ -0,0 +1,15 @@ +const React = require('react'); +const Redirect = require('../../../../../core/Redirect.js'); + +const siteConfig = require(process.cwd() + '/siteConfig.js'); + +function Docs() { + return ( + + ); +} + +module.exports = Docs; diff --git a/microsite/pages/en/docs/features/techdocs/index.js b/microsite/pages/en/docs/features/techdocs/index.js new file mode 100644 index 0000000000..c45cde24f5 --- /dev/null +++ b/microsite/pages/en/docs/features/techdocs/index.js @@ -0,0 +1,15 @@ +const React = require('react'); +const Redirect = require('../../../../../core/Redirect.js'); + +const siteConfig = require(process.cwd() + '/siteConfig.js'); + +function Docs() { + return ( + + ); +} + +module.exports = Docs; diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index 395aba8cd0..47f9568d9b 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -11,6 +11,7 @@ const Block = Components.Block; const ActionBlock = Components.ActionBlock; const Breakpoint = Components.Breakpoint; const BulletLine = Components.BulletLine; +const Banner = Components.Banner; class Index extends React.Component { render() { @@ -53,6 +54,15 @@ class Index extends React.Component { + + + 🎉 New feature: Kubernetes for service owners.{' '} + + Learn more. + + + + @@ -379,6 +389,72 @@ class Index extends React.Component { + + + + + + Backstage Kubernetes + + Manage your services, not clusters + + + + + + + Kubernetes made just for service owners + + + Backstage features the first Kubernetes monitoring tool designed + around the needs of service owners, not cluster admins + + + + + + + Your service at a glance + + + Get all your service's deployments in one, aggregated view — no + more digging through cluster logs in a CLI, no more combing + through lists of services you don't own + + + + + + Pick a cloud, any cloud + + Since Backstage uses the Kubernetes API, it's cloud agnostic — + so it works no matter which cloud provide or managed Kubernetes + service you use, and even works in multi-cloud orgs + + + + + + Any K8s, one UI + + Now you don't have to switch dashboards when you move from local + testing to production, or from one cloud provider to another + + + + + + Learn more about the K8s plugin + + Read + + + diff --git a/microsite/pages/en/plugins.js b/microsite/pages/en/plugins.js index c7a626bd1d..9ee54173f1 100644 --- a/microsite/pages/en/plugins.js +++ b/microsite/pages/en/plugins.js @@ -97,14 +97,14 @@ const Plugins = () => (

- See what plugins are already{' '} + See what plugins are already in progress - {' '} - and 👍. Missing a plugin for your favorite tool? Please{' '} + + and 👍. Missing a plugin for your favorite tool? Please suggest - {' '} + a new one.

diff --git a/microsite/sidebars.json b/microsite/sidebars.json index b31f0ccdf3..ebce8d900c 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -13,7 +13,6 @@ "Getting Started": [ "getting-started/index", "getting-started/running-backstage-locally", - "getting-started/installation", "getting-started/development-environment", "getting-started/create-an-app", { @@ -36,6 +35,15 @@ ], "CLI": ["cli/index", "cli/commands"], "Core Features": [ + { + "type": "subcategory", + "label": "Kubernetes", + "ids": [ + "features/kubernetes/overview", + "features/kubernetes/installation", + "features/kubernetes/configuration" + ] + }, { "type": "subcategory", "label": "Software Catalog", @@ -50,6 +58,7 @@ "features/software-catalog/well-known-relations", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", + "features/software-catalog/kubernetes-in-backstage", "features/software-catalog/software-catalog-api" ] }, @@ -66,6 +75,14 @@ "features/software-templates/extending/extending-preparer" ] }, + { + "type": "subcategory", + "label": "Backstage Search", + "ids": [ + "features/search/search-overview", + "features/search/architecture" + ] + }, { "type": "subcategory", "label": "TechDocs", @@ -77,6 +94,8 @@ "features/techdocs/creating-and-publishing", "features/techdocs/configuration", "features/techdocs/using-cloud-storage", + "features/techdocs/configuring-ci-cd", + "features/techdocs/how-to-guides", "features/techdocs/troubleshooting", "features/techdocs/faqs" ] @@ -89,6 +108,7 @@ "plugins/plugin-development", "plugins/structure-of-a-plugin", "plugins/integrating-plugin-into-service-catalog", + "plugins/composability", { "type": "subcategory", "label": "Backends and APIs", @@ -109,7 +129,8 @@ "ids": [ "plugins/publishing", "plugins/publish-private", - "plugins/add-to-marketplace" + "plugins/add-to-marketplace", + "plugins/observability" ] } ], @@ -166,10 +187,13 @@ "architecture-decisions/adrs-adr006", "architecture-decisions/adrs-adr007", "architecture-decisions/adrs-adr008", - "architecture-decisions/adrs-adr009" + "architecture-decisions/adrs-adr009", + "architecture-decisions/adrs-adr010", + "architecture-decisions/adrs-adr011" ], "Contribute": ["../CONTRIBUTING"], "Support": ["support/support", "support/project-structure"], + "Glossary": ["glossary"], "FAQ": ["FAQ"] } } diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index 39ba05bcad..51354343d8 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -82,6 +82,8 @@ const siteConfig = { 'https://buttons.github.io/buttons.js', 'https://unpkg.com/medium-zoom@1.0.6/dist/medium-zoom.min.js', '/js/medium-zoom.js', + '/js/dismissable-banner.js', + '/js/scroll-nav-to-view-in-docs.js', ], // On page navigation for the current documentation page. diff --git a/microsite/static/animations/backstage-kubernetes-icon-1.gif b/microsite/static/animations/backstage-kubernetes-icon-1.gif new file mode 100644 index 0000000000..a7a653a3a3 Binary files /dev/null and b/microsite/static/animations/backstage-kubernetes-icon-1.gif differ diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 46b2aa3470..c947c88e66 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -48,6 +48,11 @@ h6 { color: $textColor; } +summary { + color: $textColor; + cursor: pointer; +} + h2:hover .hash-link { opacity: 1; } @@ -1030,6 +1035,55 @@ code { } } +.Banner { + position: relative; + padding: 14px; + margin: 14px 20px; + border-radius: 4px; + background-color: $primaryColor; + font-family: Helvetica Neue, sans-serif; + color: #000; +} + +.Banner--hidden { + opacity: 0; + transition: opacity 200ms ease-in-out; +} + +.Banner a { + color: #000; + text-decoration: underline; +} + +.Banner__Container { + position: relative; + overflow: visible; + z-index: 100; + + max-width: 1430px; + height: 0; + margin: -14px auto 14px auto; +} + +.Banner__DismissButton { + position: absolute; + display: flex; + right: 8px; + top: 0; + bottom: 0; + margin: auto; + + border-radius: 50%; + padding: 6px; + width: 36px; + height: 36px; + cursor: pointer; +} + +.Banner__DismissButton:hover { + background: rgba(0, 0, 0, 0.2); +} + .logos-mobile-background { position: absolute; width: 200vw; diff --git a/microsite/static/js/dismissable-banner.js b/microsite/static/js/dismissable-banner.js new file mode 100644 index 0000000000..2fcdbe5692 --- /dev/null +++ b/microsite/static/js/dismissable-banner.js @@ -0,0 +1,18 @@ +window.addEventListener('DOMContentLoaded', () => { + const banners = document.querySelectorAll('[data-banner]'); + banners.forEach(banner => { + const storageKey = `hideBanner/${banner.getAttribute('data-banner')}`; + + if (!localStorage.getItem(storageKey)) { + banner.classList.remove('Banner--hidden'); + } + + const dismissButton = banner.querySelector('[data-banner-dismiss]'); + if (dismissButton) { + dismissButton.addEventListener('click', () => { + banner.classList.add('Banner--hidden'); + localStorage.setItem(storageKey, 'true'); + }); + } + }); +}); diff --git a/microsite/static/js/scroll-nav-to-view-in-docs.js b/microsite/static/js/scroll-nav-to-view-in-docs.js new file mode 100644 index 0000000000..0e4da26674 --- /dev/null +++ b/microsite/static/js/scroll-nav-to-view-in-docs.js @@ -0,0 +1,23 @@ +// On backstage.io/docs pages, scroll the Nav sidebar to focus on +// the page being viewed. Helpful when the Nav is large enough that +// the selected page is hidden somewhere at bottom. +// Credits: https://github.com/facebook/docusaurus/issues/823#issuecomment-421152269 +document.addEventListener('DOMContentLoaded', () => { + // Find the active nav item in the sidebar + const item = document.getElementsByClassName('navListItemActive')[0]; + if (!item) { + return; + } + const bounding = item.getBoundingClientRect(); + if ( + bounding.top >= 0 && + bounding.bottom <= + (window.innerHeight || document.documentElement.clientHeight) + ) { + // Already visible. Do nothing. + } else { + // Not visible. Scroll sidebar. + item.scrollIntoView({ block: 'start', inline: 'nearest' }); + document.body.scrollTop = document.documentElement.scrollTop = 0; + } +}); diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 356585cf3e..898fd69ad2 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -9,55 +9,54 @@ dependencies: "@babel/highlight" "^7.0.0" -"@babel/code-frame@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" - integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== +"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== dependencies: "@babel/highlight" "^7.10.4" "@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" integrity sha512-YaxPMGs/XIWtYqrdEOZOCPsVWfEoriXopnsz3/i7apYPXQ3698UFhS6dVT1KN5qOsWmVgw/FOrmQgpRaZayGsw== "@babel/core@^7.12.3": - version "7.12.9" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.9.tgz#fd450c4ec10cdbb980e2928b7aa7a28484593fc8" - integrity sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ== + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.12.10.tgz#b79a2e1b9f70ed3d84bbfb6d8c4ef825f606bccd" + integrity sha512-eTAlQKq65zHfkHZV0sIVODCPGVgoo1HdBlbSLi9CqOzuZanMv2ihzY+4paiKr1mH+XmYESMAmJ/dpZ68eN6d8w== dependencies: "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.5" + "@babel/generator" "^7.12.10" "@babel/helper-module-transforms" "^7.12.1" "@babel/helpers" "^7.12.5" - "@babel/parser" "^7.12.7" + "@babel/parser" "^7.12.10" "@babel/template" "^7.12.7" - "@babel/traverse" "^7.12.9" - "@babel/types" "^7.12.7" + "@babel/traverse" "^7.12.10" + "@babel/types" "^7.12.10" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.1" json5 "^2.1.2" lodash "^4.17.19" - resolve "^1.3.2" semver "^5.4.1" source-map "^0.5.0" -"@babel/generator@^7.12.5": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.5.tgz#a2c50de5c8b6d708ab95be5e6053936c1884a4de" - integrity sha512-m16TQQJ8hPt7E+OS/XVQg/7U184MLXtvuGbCdA7na61vha+ImkyyNM/9DDA0unYCVZn3ZOhng+qz48/KBOT96A== +"@babel/generator@^7.12.10", "@babel/generator@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.12.11.tgz#98a7df7b8c358c9a37ab07a24056853016aba3af" + integrity sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA== dependencies: - "@babel/types" "^7.12.5" + "@babel/types" "^7.12.11" jsesc "^2.5.1" source-map "^0.5.0" -"@babel/helper-annotate-as-pure@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3" - integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA== +"@babel/helper-annotate-as-pure@^7.10.4", "@babel/helper-annotate-as-pure@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d" + integrity sha512-XplmVbC1n+KY6jL8/fgLVXXUauDIB+lD5+GsQEh6F6GBF1dq1qy4DP4yXWzDKcoqXB3X58t61e85Fitoww4JVQ== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4": version "7.10.4" @@ -67,26 +66,9 @@ "@babel/helper-explode-assignable-expression" "^7.10.4" "@babel/types" "^7.10.4" -"@babel/helper-builder-react-jsx-experimental@^7.12.4": - version "7.12.4" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.12.4.tgz#55fc1ead5242caa0ca2875dcb8eed6d311e50f48" - integrity sha512-AjEa0jrQqNk7eDQOo0pTfUOwQBMF+xVqrausQwT9/rTKy0g04ggFNaJpaE09IQMn9yExluigWMJcj0WC7bq+Og== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-module-imports" "^7.12.1" - "@babel/types" "^7.12.1" - -"@babel/helper-builder-react-jsx@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.10.4.tgz#8095cddbff858e6fa9c326daee54a2f2732c1d5d" - integrity sha512-5nPcIZ7+KKDxT1427oBivl9V9YTal7qk0diccnh7RrcgrT/pGFOjgGw1dgryyx1GvHEpXVfoDF6Ak3rTiWh8Rg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/types" "^7.10.4" - "@babel/helper-compilation-targets@^7.12.5": version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" + resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.5.tgz#cb470c76198db6a24e9dbc8987275631e5d29831" integrity sha512-+qH6NrscMolUlzOYngSBMIOQpKUGPPsc61Bu5W10mg84LxZ7cmvnBHzARKbDoFxVvqqAbj6Tg6N7bSrWSPXMyw== dependencies: "@babel/compat-data" "^7.12.5" @@ -96,7 +78,7 @@ "@babel/helper-create-class-features-plugin@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz#3c45998f431edd4a9214c5f1d3ad1448a6137f6e" + resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz#3c45998f431edd4a9214c5f1d3ad1448a6137f6e" integrity sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w== dependencies: "@babel/helper-function-name" "^7.10.4" @@ -105,18 +87,9 @@ "@babel/helper-replace-supers" "^7.12.1" "@babel/helper-split-export-declaration" "^7.10.4" -"@babel/helper-create-regexp-features-plugin@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8" - integrity sha512-2/hu58IEPKeoLF45DBwx3XFqsbCXmkdAay4spVr2x0jYgRxrSNp+ePwvSsy9g6YSaNDcKIQVPXk1Ov8S2edk2g== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-regex" "^7.10.4" - regexpu-core "^4.7.0" - "@babel/helper-create-regexp-features-plugin@^7.12.1": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.7.tgz#2084172e95443fa0a09214ba1bb328f9aea1278f" + resolved "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.7.tgz#2084172e95443fa0a09214ba1bb328f9aea1278f" integrity sha512-idnutvQPdpbduutvi3JVfEgcVIHooQnhvhx0Nk9isOINOIGYkZea1Pk2JlJRiUnMefrlvr0vkByATBY/mB4vjQ== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" @@ -132,27 +105,27 @@ lodash "^4.17.19" "@babel/helper-explode-assignable-expression@^7.10.4": - version "7.11.4" - resolved "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.11.4.tgz#2d8e3470252cc17aba917ede7803d4a7a276a41b" - integrity sha512-ux9hm3zR4WV1Y3xXxXkdG/0gxF9nvI0YVmKVhvK9AfMoaQkemL3sJpXw+Xbz65azo8qJiEz2XVDUpK3KYhH3ZQ== + version "7.12.1" + resolved "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.12.1.tgz#8006a466695c4ad86a2a5f2fb15b5f2c31ad5633" + integrity sha512-dmUwH8XmlrUpVqgtZ737tK88v07l840z9j3OEhCLwKTkjlvKpfqXVIZ0wpK3aeOxspwGrf/5AP5qLx4rO3w5rA== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.1" -"@babel/helper-function-name@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a" - integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ== +"@babel/helper-function-name@^7.10.4", "@babel/helper-function-name@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.11.tgz#1fd7738aee5dcf53c3ecff24f1da9c511ec47b42" + integrity sha512-AtQKjtYNolKNi6nNNVLQ27CP6D9oFR6bq/HPYSizlzbp7uC1M59XJe8L+0uXjbIaZaUJF99ruHqVGiKXU/7ybA== dependencies: - "@babel/helper-get-function-arity" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/helper-get-function-arity" "^7.12.10" + "@babel/template" "^7.12.7" + "@babel/types" "^7.12.11" -"@babel/helper-get-function-arity@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" - integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A== +"@babel/helper-get-function-arity@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" + integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" @@ -161,23 +134,23 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-member-expression-to-functions@^7.12.1": +"@babel/helper-member-expression-to-functions@^7.12.1", "@babel/helper-member-expression-to-functions@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" + resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855" integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw== dependencies: "@babel/types" "^7.12.7" "@babel/helper-module-imports@^7.12.1", "@babel/helper-module-imports@^7.12.5": version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb" integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA== dependencies: "@babel/types" "^7.12.5" "@babel/helper-module-transforms@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w== dependencies: "@babel/helper-module-imports" "^7.12.1" @@ -190,28 +163,21 @@ "@babel/types" "^7.12.1" lodash "^4.17.19" -"@babel/helper-optimise-call-expression@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" - integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg== +"@babel/helper-optimise-call-expression@^7.10.4", "@babel/helper-optimise-call-expression@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d" + integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ== dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.12.10" "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== -"@babel/helper-regex@^7.10.4": - version "7.10.5" - resolved "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0" - integrity sha512-68kdUAzDrljqBrio7DYAEgCoJHxppJOERHOgOrDN7WjOzP0ZQ1LsSDRXcemzVZaLvjaJsJEESb6qt+znNuENDg== - dependencies: - lodash "^4.17.19" - "@babel/helper-remap-async-to-generator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" + resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" integrity sha512-9d0KQCRM8clMPcDwo8SevNs+/9a8yWVVmaE80FGJcEP8N1qToREmWEGnBn8BUlJhYRFz6fqxeRL1sl5Ogsed7A== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" @@ -219,50 +185,50 @@ "@babel/types" "^7.12.1" "@babel/helper-replace-supers@^7.12.1": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.12.5.tgz#f009a17543bbbbce16b06206ae73b63d3fca68d9" - integrity sha512-5YILoed0ZyIpF4gKcpZitEnXEJ9UoDRki1Ey6xz46rxOzfNMAhVIJMoune1hmPVxh40LRv1+oafz7UsWX+vyWA== + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.12.11.tgz#ea511658fc66c7908f923106dd88e08d1997d60d" + integrity sha512-q+w1cqmhL7R0FNzth/PLLp2N+scXEK/L2AHbXUyydxp828F4FEa5WcVoqui9vFRiHDQErj9Zof8azP32uGVTRA== dependencies: - "@babel/helper-member-expression-to-functions" "^7.12.1" - "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/traverse" "^7.12.5" - "@babel/types" "^7.12.5" + "@babel/helper-member-expression-to-functions" "^7.12.7" + "@babel/helper-optimise-call-expression" "^7.12.10" + "@babel/traverse" "^7.12.10" + "@babel/types" "^7.12.11" "@babel/helper-simple-access@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" + resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA== dependencies: "@babel/types" "^7.12.1" "@babel/helper-skip-transparent-expression-wrappers@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf" + resolved "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf" integrity sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA== dependencies: "@babel/types" "^7.12.1" -"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" - integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg== +"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0", "@babel/helper-split-export-declaration@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.11.tgz#1b4cc424458643c47d37022223da33d76ea4603a" + integrity sha512-LsIVN8j48gHgwzfocYUSkO/hjYAOJqlpJEc7tGXcIm4cubjVUf8LGW6eWRyxEu7gA25q02p0rQUWoCI33HNS5g== dependencies: - "@babel/types" "^7.11.0" + "@babel/types" "^7.12.11" -"@babel/helper-validator-identifier@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" - integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== +"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" + integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== -"@babel/helper-validator-option@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.1.tgz#175567380c3e77d60ff98a54bb015fe78f2178d9" - integrity sha512-YpJabsXlJVWP0USHjnC/AQDTLlZERbON577YUVO/wLpqyj6HAtVYnWaQaN0iUN+1/tWn3c+uKKXjRut5115Y2A== +"@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f" + integrity sha512-TBFCyj939mFSdeX7U7DDj32WtzYY7fDcalgq8v3fBZMNOJQNn7nOYzMaUCiPxPYfCup69mtIpqlKgMZLvQ8Xhw== "@babel/helper-wrap-function@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87" - integrity sha512-6py45WvEF0MhiLrdxtRjKjufwLL1/ob2qDJgg5JgNdojBAZSAKnAjkyOCNug6n+OBl4VW76XjvgSFTdaMcW0Ug== + version "7.12.3" + resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.12.3.tgz#3332339fc4d1fbbf1c27d7958c27d34708e990d9" + integrity sha512-Cvb8IuJDln3rs6tzjW3Y8UeelAOdnpB8xtQ4sme2MSZ9wOxrbThporC0y/EtE16VAtoyEfLM404Xr1e0OOp+ow== dependencies: "@babel/helper-function-name" "^7.10.4" "@babel/template" "^7.10.4" @@ -271,7 +237,7 @@ "@babel/helpers@^7.12.5": version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e" integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA== dependencies: "@babel/template" "^7.10.4" @@ -287,20 +253,15 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.10.4": - version "7.11.5" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" - integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== - -"@babel/parser@^7.12.7": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.7.tgz#fee7b39fe809d0e73e5b25eecaf5780ef3d73056" - integrity sha512-oWR02Ubp4xTLCAqPRiNIuMVgNO5Aif/xpXtabhzW2HWUD47XJsAB4Zd/Rg30+XeQA3juXigV7hlquOTmwqLiwg== +"@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" + integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== "@babel/plugin-proposal-async-generator-functions@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.1.tgz#dc6c1170e27d8aca99ff65f4925bd06b1c90550e" - integrity sha512-d+/o30tJxFxrA1lhzJqiUcEJdI6jKlNregCv5bASeGf2Q4MXmnwH7viDo7nhx1/ohf09oaH8j1GVYG/e3Yqk6A== + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.12.tgz#04b8f24fd4532008ab4e79f788468fd5a8476566" + integrity sha512-nrz9y0a4xmUrRq51bYkWJIO5SBZyG2ys2qinHsN0zHDHVsUaModrkpyWWWXfGqYQmOL3x9sQIcTNN/pBGpo09A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/helper-remap-async-to-generator" "^7.12.1" @@ -308,7 +269,7 @@ "@babel/plugin-proposal-class-properties@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz#a082ff541f2a29a4821065b8add9346c0c16e5de" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz#a082ff541f2a29a4821065b8add9346c0c16e5de" integrity sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w== dependencies: "@babel/helper-create-class-features-plugin" "^7.12.1" @@ -316,7 +277,7 @@ "@babel/plugin-proposal-dynamic-import@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz#43eb5c2a3487ecd98c5c8ea8b5fdb69a2749b2dc" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz#43eb5c2a3487ecd98c5c8ea8b5fdb69a2749b2dc" integrity sha512-a4rhUSZFuq5W8/OO8H7BL5zspjnc1FLd9hlOxIK/f7qG4a0qsqk8uvF/ywgBA8/OmjsapjpvaEOYItfGG1qIvQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -324,7 +285,7 @@ "@babel/plugin-proposal-export-namespace-from@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.1.tgz#8b9b8f376b2d88f5dd774e4d24a5cc2e3679b6d4" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.1.tgz#8b9b8f376b2d88f5dd774e4d24a5cc2e3679b6d4" integrity sha512-6CThGf0irEkzujYS5LQcjBx8j/4aQGiVv7J9+2f7pGfxqyKh3WnmVJYW3hdrQjyksErMGBPQrCnHfOtna+WLbw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -332,7 +293,7 @@ "@babel/plugin-proposal-json-strings@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.1.tgz#d45423b517714eedd5621a9dfdc03fa9f4eb241c" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.1.tgz#d45423b517714eedd5621a9dfdc03fa9f4eb241c" integrity sha512-GoLDUi6U9ZLzlSda2Df++VSqDJg3CG+dR0+iWsv6XRw1rEq+zwt4DirM9yrxW6XWaTpmai1cWJLMfM8qQJf+yw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -340,7 +301,7 @@ "@babel/plugin-proposal-logical-assignment-operators@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.1.tgz#f2c490d36e1b3c9659241034a5d2cd50263a2751" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.1.tgz#f2c490d36e1b3c9659241034a5d2cd50263a2751" integrity sha512-k8ZmVv0JU+4gcUGeCDZOGd0lCIamU/sMtIiX3UWnUc5yzgq6YUGyEolNYD+MLYKfSzgECPcqetVcJP9Afe/aCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -348,7 +309,7 @@ "@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.1.tgz#3ed4fff31c015e7f3f1467f190dbe545cd7b046c" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.1.tgz#3ed4fff31c015e7f3f1467f190dbe545cd7b046c" integrity sha512-nZY0ESiaQDI1y96+jk6VxMOaL4LPo/QDHBqL+SF3/vl6dHkTwHlOI8L4ZwuRBHgakRBw5zsVylel7QPbbGuYgg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -356,7 +317,7 @@ "@babel/plugin-proposal-numeric-separator@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.7.tgz#8bf253de8139099fea193b297d23a9d406ef056b" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.7.tgz#8bf253de8139099fea193b297d23a9d406ef056b" integrity sha512-8c+uy0qmnRTeukiGsjLGy6uVs/TFjJchGXUeBqlG4VWYOdJWkhhVPdQ3uHwbmalfJwv2JsV0qffXP4asRfL2SQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -364,7 +325,7 @@ "@babel/plugin-proposal-object-rest-spread@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" integrity sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -373,7 +334,7 @@ "@babel/plugin-proposal-optional-catch-binding@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.1.tgz#ccc2421af64d3aae50b558a71cede929a5ab2942" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.1.tgz#ccc2421af64d3aae50b558a71cede929a5ab2942" integrity sha512-hFvIjgprh9mMw5v42sJWLI1lzU5L2sznP805zeT6rySVRA0Y18StRhDqhSxlap0oVgItRsB6WSROp4YnJTJz0g== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -381,7 +342,7 @@ "@babel/plugin-proposal-optional-chaining@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.7.tgz#e02f0ea1b5dc59d401ec16fb824679f683d3303c" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.7.tgz#e02f0ea1b5dc59d401ec16fb824679f683d3303c" integrity sha512-4ovylXZ0PWmwoOvhU2vhnzVNnm88/Sm9nx7V8BPgMvAzn5zDou3/Awy0EjglyubVHasJj+XCEkr/r1X3P5elCA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -390,28 +351,20 @@ "@babel/plugin-proposal-private-methods@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" integrity sha512-mwZ1phvH7/NHK6Kf8LP7MYDogGV+DKB1mryFOEwx5EBNQrosvIczzZFTUmWaeujd5xT6G1ELYWUz3CutMhjE1w== dependencies: "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^7.12.1": +"@babel/plugin-proposal-unicode-property-regex@^7.12.1", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.1.tgz#2a183958d417765b9eae334f47758e5d6a82e072" + resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.1.tgz#2a183958d417765b9eae334f47758e5d6a82e072" integrity sha512-MYq+l+PvHuw/rKUz1at/vb6nCnQ2gmJBNaM62z0OgH7B2W1D9pvkpYtlti9bGtizNIU1K3zm4bZF9F91efVY0w== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-unicode-property-regex@^7.4.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d" - integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-async-generators@^7.8.0": version "7.8.4" resolved "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -421,7 +374,7 @@ "@babel/plugin-syntax-class-properties@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" integrity sha512-U40A76x5gTwmESz+qiqssqmeEsKvcSyvtgktrm0uzcARAmM9I1jR221f6Oq+GmHrcD+LvZDag1UTOTe2fL3TeA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -449,7 +402,7 @@ "@babel/plugin-syntax-jsx@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz#9d9d357cc818aa7ae7935917c1257f67677a0926" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz#9d9d357cc818aa7ae7935917c1257f67677a0926" integrity sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -498,21 +451,21 @@ "@babel/plugin-syntax-top-level-await@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz#dd6c0b357ac1bb142d98537450a319625d13d2a0" + resolved "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz#dd6c0b357ac1bb142d98537450a319625d13d2a0" integrity sha512-i7ooMZFS+a/Om0crxZodrTzNEPJHZrlMVGMTEpFAj6rYY/bKCddB0Dk/YxfPuYXOopuhKk/e1jV6h+WUU9XN3A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-arrow-functions@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz#8083ffc86ac8e777fbe24b5967c4b2521f3cb2b3" + resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz#8083ffc86ac8e777fbe24b5967c4b2521f3cb2b3" integrity sha512-5QB50qyN44fzzz4/qxDPQMBCTHgxg3n0xRBLJUmBlLoU/sFvxVWGZF/ZUfMVDQuJUKXaBhbupxIzIfZ6Fwk/0A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-async-to-generator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.1.tgz#3849a49cc2a22e9743cbd6b52926d30337229af1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.1.tgz#3849a49cc2a22e9743cbd6b52926d30337229af1" integrity sha512-SDtqoEcarK1DFlRJ1hHRY5HvJUj5kX4qmtpMAm2QnhOlyuMC4TMdCRgW6WXpv93rZeYNeLP22y8Aq2dbcDRM1A== dependencies: "@babel/helper-module-imports" "^7.12.1" @@ -521,21 +474,21 @@ "@babel/plugin-transform-block-scoped-functions@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.1.tgz#f2a1a365bde2b7112e0a6ded9067fdd7c07905d9" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.1.tgz#f2a1a365bde2b7112e0a6ded9067fdd7c07905d9" integrity sha512-5OpxfuYnSgPalRpo8EWGPzIYf0lHBWORCkj5M0oLBwHdlux9Ri36QqGW3/LR13RSVOAoUUMzoPI/jpE4ABcHoA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-block-scoping@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.1.tgz#f0ee727874b42a208a48a586b84c3d222c2bbef1" - integrity sha512-zJyAC9sZdE60r1nVQHblcfCj29Dh2Y0DOvlMkcqSo0ckqjiCwNiUezUKw+RjOCwGfpLRwnAeQ2XlLpsnGkvv9w== +"@babel/plugin-transform-block-scoping@^7.12.11": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.12.tgz#d93a567a152c22aea3b1929bb118d1d0a175cdca" + integrity sha512-VOEPQ/ExOVqbukuP7BYJtI5ZxxsmegTwzZ04j1aF0dkSypGo9XpDHuOrABsJu+ie+penpSJheDJ11x1BEZNiyQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-classes@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.1.tgz#65e650fcaddd3d88ddce67c0f834a3d436a32db6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.1.tgz#65e650fcaddd3d88ddce67c0f834a3d436a32db6" integrity sha512-/74xkA7bVdzQTBeSUhLLJgYIcxw/dpEpCdRDiHgPJ3Mv6uC11UhjpOhl72CgqbBCmt1qtssCyB2xnJm1+PFjog== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" @@ -549,44 +502,36 @@ "@babel/plugin-transform-computed-properties@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.1.tgz#d68cf6c9b7f838a8a4144badbe97541ea0904852" + resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.1.tgz#d68cf6c9b7f838a8a4144badbe97541ea0904852" integrity sha512-vVUOYpPWB7BkgUWPo4C44mUQHpTZXakEqFjbv8rQMg7TC6S6ZhGZ3otQcRH6u7+adSlE5i0sp63eMC/XGffrzg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-destructuring@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.1.tgz#b9a570fe0d0a8d460116413cb4f97e8e08b2f847" + resolved "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.1.tgz#b9a570fe0d0a8d460116413cb4f97e8e08b2f847" integrity sha512-fRMYFKuzi/rSiYb2uRLiUENJOKq4Gnl+6qOv5f8z0TZXg3llUwUhsNNwrwaT/6dUhJTzNpBr+CUvEWBtfNY1cw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-dotall-regex@^7.12.1": +"@babel/plugin-transform-dotall-regex@^7.12.1", "@babel/plugin-transform-dotall-regex@^7.4.4": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.1.tgz#a1d16c14862817b6409c0a678d6f9373ca9cd975" + resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.1.tgz#a1d16c14862817b6409c0a678d6f9373ca9cd975" integrity sha512-B2pXeRKoLszfEW7J4Hg9LoFaWEbr/kzo3teWHmtFCszjRNa/b40f9mfeqZsIDLLt/FjwQ6pz/Gdlwy85xNckBA== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-dotall-regex@^7.4.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee" - integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-transform-duplicate-keys@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.1.tgz#745661baba295ac06e686822797a69fbaa2ca228" + resolved "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.1.tgz#745661baba295ac06e686822797a69fbaa2ca228" integrity sha512-iRght0T0HztAb/CazveUpUQrZY+aGKKaWXMJ4uf9YJtqxSUe09j3wteztCUDRHs+SRAL7yMuFqUsLoAKKzgXjw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-exponentiation-operator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.1.tgz#b0f2ed356ba1be1428ecaf128ff8a24f02830ae0" + resolved "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.1.tgz#b0f2ed356ba1be1428ecaf128ff8a24f02830ae0" integrity sha512-7tqwy2bv48q+c1EHbXK0Zx3KXd2RVQp6OC7PbwFNt/dPTAV3Lu5sWtWuAj8owr5wqtWnqHfl2/mJlUmqkChKug== dependencies: "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4" @@ -594,14 +539,14 @@ "@babel/plugin-transform-for-of@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" + resolved "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" integrity sha512-Zaeq10naAsuHo7heQvyV0ptj4dlZJwZgNAtBYBnu5nNKJoW62m0zKcIEyVECrUKErkUkg6ajMy4ZfnVZciSBhg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-function-name@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.1.tgz#2ec76258c70fe08c6d7da154003a480620eba667" + resolved "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.1.tgz#2ec76258c70fe08c6d7da154003a480620eba667" integrity sha512-JF3UgJUILoFrFMEnOJLJkRHSk6LUSXLmEFsA23aR2O5CSLUxbeUX1IZ1YQ7Sn0aXb601Ncwjx73a+FVqgcljVw== dependencies: "@babel/helper-function-name" "^7.10.4" @@ -609,21 +554,21 @@ "@babel/plugin-transform-literals@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.1.tgz#d73b803a26b37017ddf9d3bb8f4dc58bfb806f57" + resolved "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.1.tgz#d73b803a26b37017ddf9d3bb8f4dc58bfb806f57" integrity sha512-+PxVGA+2Ag6uGgL0A5f+9rklOnnMccwEBzwYFL3EUaKuiyVnUipyXncFcfjSkbimLrODoqki1U9XxZzTvfN7IQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-member-expression-literals@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.1.tgz#496038602daf1514a64d43d8e17cbb2755e0c3ad" + resolved "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.1.tgz#496038602daf1514a64d43d8e17cbb2755e0c3ad" integrity sha512-1sxePl6z9ad0gFMB9KqmYofk34flq62aqMt9NqliS/7hPEpURUCMbyHXrMPlo282iY7nAvUB1aQd5mg79UD9Jg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-modules-amd@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.1.tgz#3154300b026185666eebb0c0ed7f8415fefcf6f9" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.1.tgz#3154300b026185666eebb0c0ed7f8415fefcf6f9" integrity sha512-tDW8hMkzad5oDtzsB70HIQQRBiTKrhfgwC/KkJeGsaNFTdWhKNt/BiE8c5yj19XiGyrxpbkOfH87qkNg1YGlOQ== dependencies: "@babel/helper-module-transforms" "^7.12.1" @@ -632,7 +577,7 @@ "@babel/plugin-transform-modules-commonjs@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" integrity sha512-dY789wq6l0uLY8py9c1B48V8mVL5gZh/+PQ5ZPrylPYsnAvnEMjqsUXkuoDVPeVK+0VyGar+D08107LzDQ6pag== dependencies: "@babel/helper-module-transforms" "^7.12.1" @@ -642,7 +587,7 @@ "@babel/plugin-transform-modules-systemjs@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.1.tgz#663fea620d593c93f214a464cd399bf6dc683086" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.1.tgz#663fea620d593c93f214a464cd399bf6dc683086" integrity sha512-Hn7cVvOavVh8yvW6fLwveFqSnd7rbQN3zJvoPNyNaQSvgfKmDBO9U1YL9+PCXGRlZD9tNdWTy5ACKqMuzyn32Q== dependencies: "@babel/helper-hoist-variables" "^7.10.4" @@ -653,7 +598,7 @@ "@babel/plugin-transform-modules-umd@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.1.tgz#eb5a218d6b1c68f3d6217b8fa2cc82fec6547902" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.1.tgz#eb5a218d6b1c68f3d6217b8fa2cc82fec6547902" integrity sha512-aEIubCS0KHKM0zUos5fIoQm+AZUMt1ZvMpqz0/H5qAQ7vWylr9+PLYurT+Ic7ID/bKLd4q8hDovaG3Zch2uz5Q== dependencies: "@babel/helper-module-transforms" "^7.12.1" @@ -661,21 +606,21 @@ "@babel/plugin-transform-named-capturing-groups-regex@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.1.tgz#b407f5c96be0d9f5f88467497fa82b30ac3e8753" + resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.1.tgz#b407f5c96be0d9f5f88467497fa82b30ac3e8753" integrity sha512-tB43uQ62RHcoDp9v2Nsf+dSM8sbNodbEicbQNA53zHz8pWUhsgHSJCGpt7daXxRydjb0KnfmB+ChXOv3oADp1Q== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" "@babel/plugin-transform-new-target@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.1.tgz#80073f02ee1bb2d365c3416490e085c95759dec0" + resolved "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.1.tgz#80073f02ee1bb2d365c3416490e085c95759dec0" integrity sha512-+eW/VLcUL5L9IvJH7rT1sT0CzkdUTvPrXC2PXTn/7z7tXLBuKvezYbGdxD5WMRoyvyaujOq2fWoKl869heKjhw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-object-super@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.1.tgz#4ea08696b8d2e65841d0c7706482b048bed1066e" + resolved "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.1.tgz#4ea08696b8d2e65841d0c7706482b048bed1066e" integrity sha512-AvypiGJH9hsquNUn+RXVcBdeE3KHPZexWRdimhuV59cSoOt5kFBmqlByorAeUlGG2CJWd0U+4ZtNKga/TB0cAw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -683,61 +628,46 @@ "@babel/plugin-transform-parameters@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.1.tgz#d2e963b038771650c922eff593799c96d853255d" + resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.1.tgz#d2e963b038771650c922eff593799c96d853255d" integrity sha512-xq9C5EQhdPK23ZeCdMxl8bbRnAgHFrw5EOC3KJUsSylZqdkCaFEXxGSBuTSObOpiiHHNyb82es8M1QYgfQGfNg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-property-literals@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.1.tgz#41bc81200d730abb4456ab8b3fbd5537b59adecd" + resolved "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.1.tgz#41bc81200d730abb4456ab8b3fbd5537b59adecd" integrity sha512-6MTCR/mZ1MQS+AwZLplX4cEySjCpnIF26ToWo942nqn8hXSm7McaHQNeGx/pt7suI1TWOWMfa/NgBhiqSnX0cQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-react-display-name@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.1.tgz#1cbcd0c3b1d6648c55374a22fc9b6b7e5341c00d" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.1.tgz#1cbcd0c3b1d6648c55374a22fc9b6b7e5341c00d" integrity sha512-cAzB+UzBIrekfYxyLlFqf/OagTvHLcVBb5vpouzkYkBclRPraiygVnafvAoipErZLI8ANv8Ecn6E/m5qPXD26w== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-react-jsx-development@^7.12.7": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.7.tgz#4c2a647de79c7e2b16bfe4540677ba3121e82a08" - integrity sha512-Rs3ETtMtR3VLXFeYRChle5SsP/P9Jp/6dsewBQfokDSzKJThlsuFcnzLTDRALiUmTC48ej19YD9uN1mupEeEDg== + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.12.tgz#bccca33108fe99d95d7f9e82046bfe762e71f4e7" + integrity sha512-i1AxnKxHeMxUaWVXQOSIco4tvVvvCxMSfeBMnMM06mpaJt3g+MpxYQQrDfojUQldP1xxraPSJYSMEljoWM/dCg== dependencies: - "@babel/helper-builder-react-jsx-experimental" "^7.12.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.12.1" - -"@babel/plugin-transform-react-jsx-self@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.12.1.tgz#ef43cbca2a14f1bd17807dbe4376ff89d714cf28" - integrity sha512-FbpL0ieNWiiBB5tCldX17EtXgmzeEZjFrix72rQYeq9X6nUK38HCaxexzVQrZWXanxKJPKVVIU37gFjEQYkPkA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-react-jsx-source@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.12.1.tgz#d07de6863f468da0809edcf79a1aa8ce2a82a26b" - integrity sha512-keQ5kBfjJNRc6zZN1/nVHCd6LLIHq4aUKcVnvE/2l+ZZROSbqoiGFRtT5t3Is89XJxBQaP7NLZX2jgGHdZvvFQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-react-jsx@^7.12.7": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.7.tgz#8b14d45f6eccd41b7f924bcb65c021e9f0a06f7f" - integrity sha512-YFlTi6MEsclFAPIDNZYiCRbneg1MFGao9pPG9uD5htwE0vDbPaMUMeYd6itWjw7K4kro4UbdQf3ljmFl9y48dQ== - dependencies: - "@babel/helper-builder-react-jsx" "^7.10.4" - "@babel/helper-builder-react-jsx-experimental" "^7.12.4" + "@babel/plugin-transform-react-jsx" "^7.12.12" + +"@babel/plugin-transform-react-jsx@^7.12.10", "@babel/plugin-transform-react-jsx@^7.12.12": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.12.tgz#b0da51ffe5f34b9a900e9f1f5fb814f9e512d25e" + integrity sha512-JDWGuzGNWscYcq8oJVCtSE61a5+XAOos+V0HrxnDieUus4UMnBEosDnY1VJqU5iZ4pA04QY7l0+JvHL1hZEfsw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.12.10" + "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-jsx" "^7.12.1" + "@babel/types" "^7.12.12" "@babel/plugin-transform-react-pure-annotations@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.12.1.tgz#05d46f0ab4d1339ac59adf20a1462c91b37a1a42" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.12.1.tgz#05d46f0ab4d1339ac59adf20a1462c91b37a1a42" integrity sha512-RqeaHiwZtphSIUZ5I85PEH19LOSzxfuEazoY7/pWASCAIBuATQzpSVD+eT6MebeeZT2F4eSL0u4vw6n4Nm0Mjg== dependencies: "@babel/helper-annotate-as-pure" "^7.10.4" @@ -745,28 +675,28 @@ "@babel/plugin-transform-regenerator@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz#5f0a28d842f6462281f06a964e88ba8d7ab49753" + resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz#5f0a28d842f6462281f06a964e88ba8d7ab49753" integrity sha512-gYrHqs5itw6i4PflFX3OdBPMQdPbF4bj2REIUxlMRUFk0/ZOAIpDFuViuxPjUL7YC8UPnf+XG7/utJvqXdPKng== dependencies: regenerator-transform "^0.14.2" "@babel/plugin-transform-reserved-words@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.1.tgz#6fdfc8cc7edcc42b36a7c12188c6787c873adcd8" + resolved "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.1.tgz#6fdfc8cc7edcc42b36a7c12188c6787c873adcd8" integrity sha512-pOnUfhyPKvZpVyBHhSBoX8vfA09b7r00Pmm1sH+29ae2hMTKVmSp4Ztsr8KBKjLjx17H0eJqaRC3bR2iThM54A== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-shorthand-properties@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.1.tgz#0bf9cac5550fce0cfdf043420f661d645fdc75e3" + resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.1.tgz#0bf9cac5550fce0cfdf043420f661d645fdc75e3" integrity sha512-GFZS3c/MhX1OusqB1MZ1ct2xRzX5ppQh2JU1h2Pnfk88HtFTM+TWQqJNfwkmxtPQtb/s1tk87oENfXJlx7rSDw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-spread@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.1.tgz#527f9f311be4ec7fdc2b79bb89f7bf884b3e1e1e" + resolved "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.1.tgz#527f9f311be4ec7fdc2b79bb89f7bf884b3e1e1e" integrity sha512-vuLp8CP0BE18zVYjsEBZ5xoCecMK6LBMMxYzJnh01rxQRvhNhH1csMMmBfNo5tGpGO+NhdSNW2mzIvBu3K1fng== dependencies: "@babel/helper-plugin-utils" "^7.10.4" @@ -774,35 +704,35 @@ "@babel/plugin-transform-sticky-regex@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.7.tgz#560224613ab23987453948ed21d0b0b193fa7fad" + resolved "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.7.tgz#560224613ab23987453948ed21d0b0b193fa7fad" integrity sha512-VEiqZL5N/QvDbdjfYQBhruN0HYjSPjC4XkeqW4ny/jNtH9gcbgaqBIXYEZCNnESMAGs0/K/R7oFGMhOyu/eIxg== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-template-literals@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.1.tgz#b43ece6ed9a79c0c71119f576d299ef09d942843" + resolved "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.1.tgz#b43ece6ed9a79c0c71119f576d299ef09d942843" integrity sha512-b4Zx3KHi+taXB1dVRBhVJtEPi9h1THCeKmae2qP0YdUHIFhVjtpqqNfxeVAa1xeHVhAy4SbHxEwx5cltAu5apw== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-typeof-symbol@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.1.tgz#9ca6be343d42512fbc2e68236a82ae64bc7af78a" - integrity sha512-EPGgpGy+O5Kg5pJFNDKuxt9RdmTgj5sgrus2XVeMp/ZIbOESadgILUbm50SNpghOh3/6yrbsH+NB5+WJTmsA7Q== +"@babel/plugin-transform-typeof-symbol@^7.12.10": + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.10.tgz#de01c4c8f96580bd00f183072b0d0ecdcf0dec4b" + integrity sha512-JQ6H8Rnsogh//ijxspCjc21YPd3VLVoYtAwv3zQmqAt8YGYUtdo5usNhdl4b9/Vir2kPFZl6n1h0PfUz4hJhaA== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-unicode-escapes@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz#5232b9f81ccb07070b7c3c36c67a1b78f1845709" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz#5232b9f81ccb07070b7c3c36c67a1b78f1845709" integrity sha512-I8gNHJLIc7GdApm7wkVnStWssPNbSRMPtgHdmH3sRM1zopz09UWPS4x5V4n1yz/MIWTVnJ9sp6IkuXdWM4w+2Q== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-unicode-regex@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.1.tgz#cc9661f61390db5c65e3febaccefd5c6ac3faecb" + resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.1.tgz#cc9661f61390db5c65e3febaccefd5c6ac3faecb" integrity sha512-SqH4ClNngh/zGwHZOOQMTD+e8FGWexILV+ePMyiDJttAWRh5dhDL8rcl5lSgU3Huiq6Zn6pWTMvdPAb21Dwdyg== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.1" @@ -810,22 +740,22 @@ "@babel/polyfill@^7.12.1": version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.12.1.tgz#1f2d6371d1261bbd961f3c5d5909150e12d0bd96" + resolved "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz#1f2d6371d1261bbd961f3c5d5909150e12d0bd96" integrity sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g== dependencies: core-js "^2.6.5" regenerator-runtime "^0.13.4" "@babel/preset-env@^7.12.1": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.12.7.tgz#54ea21dbe92caf6f10cb1a0a576adc4ebf094b55" - integrity sha512-OnNdfAr1FUQg7ksb7bmbKoby4qFOHw6DKWWUNB9KqnnCldxhxJlP+21dpyaWFmf2h0rTbOkXJtAGevY3XW1eew== + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.12.11.tgz#55d5f7981487365c93dbbc84507b1c7215e857f9" + integrity sha512-j8Tb+KKIXKYlDBQyIOy4BLxzv1NUOwlHfZ74rvW+Z0Gp4/cI2IMDPBWAgWceGcE7aep9oL/0K9mlzlMGxA8yNw== dependencies: "@babel/compat-data" "^7.12.7" "@babel/helper-compilation-targets" "^7.12.5" "@babel/helper-module-imports" "^7.12.5" "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-validator-option" "^7.12.1" + "@babel/helper-validator-option" "^7.12.11" "@babel/plugin-proposal-async-generator-functions" "^7.12.1" "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-dynamic-import" "^7.12.1" @@ -854,7 +784,7 @@ "@babel/plugin-transform-arrow-functions" "^7.12.1" "@babel/plugin-transform-async-to-generator" "^7.12.1" "@babel/plugin-transform-block-scoped-functions" "^7.12.1" - "@babel/plugin-transform-block-scoping" "^7.12.1" + "@babel/plugin-transform-block-scoping" "^7.12.11" "@babel/plugin-transform-classes" "^7.12.1" "@babel/plugin-transform-computed-properties" "^7.12.1" "@babel/plugin-transform-destructuring" "^7.12.1" @@ -880,12 +810,12 @@ "@babel/plugin-transform-spread" "^7.12.1" "@babel/plugin-transform-sticky-regex" "^7.12.7" "@babel/plugin-transform-template-literals" "^7.12.1" - "@babel/plugin-transform-typeof-symbol" "^7.12.1" + "@babel/plugin-transform-typeof-symbol" "^7.12.10" "@babel/plugin-transform-unicode-escapes" "^7.12.1" "@babel/plugin-transform-unicode-regex" "^7.12.1" "@babel/preset-modules" "^0.1.3" - "@babel/types" "^7.12.7" - core-js-compat "^3.7.0" + "@babel/types" "^7.12.11" + core-js-compat "^3.8.0" semver "^5.5.0" "@babel/preset-modules@^0.1.3": @@ -900,22 +830,20 @@ esutils "^2.0.2" "@babel/preset-react@^7.12.5": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.12.7.tgz#36d61d83223b07b6ac4ec55cf016abb0f70be83b" - integrity sha512-wKeTdnGUP5AEYCYQIMeXMMwU7j+2opxrG0WzuZfxuuW9nhKvvALBjl67653CWamZJVefuJGI219G591RSldrqQ== + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.12.10.tgz#4fed65f296cbb0f5fb09de6be8cddc85cc909be9" + integrity sha512-vtQNjaHRl4DUpp+t+g4wvTHsLQuye+n0H/wsXIZRn69oz/fvNC7gQ4IK73zGJBaxvHoxElDvnYCthMcT7uzFoQ== dependencies: "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-transform-react-display-name" "^7.12.1" - "@babel/plugin-transform-react-jsx" "^7.12.7" + "@babel/plugin-transform-react-jsx" "^7.12.10" "@babel/plugin-transform-react-jsx-development" "^7.12.7" - "@babel/plugin-transform-react-jsx-self" "^7.12.1" - "@babel/plugin-transform-react-jsx-source" "^7.12.1" "@babel/plugin-transform-react-pure-annotations" "^7.12.1" "@babel/register@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.12.1.tgz#cdb087bdfc4f7241c03231f22e15d211acf21438" - integrity sha512-XWcmseMIncOjoydKZnWvWi0/5CUCD+ZYKhRwgYlWOrA8fGZ/FjuLRpqtIhLOVD/fvR1b9DQHtZPn68VvhpYf+Q== + version "7.12.10" + resolved "https://registry.npmjs.org/@babel/register/-/register-7.12.10.tgz#19b87143f17128af4dbe7af54c735663b3999f60" + integrity sha512-EvX/BvMMJRAA3jZgILWgbsrHwBQvllC5T8B29McyME8DvkdOxk4ujESfrMvME8IHSDvWXrmMXxPvA/lx2gqPLQ== dependencies: find-cache-dir "^2.0.0" lodash "^4.17.19" @@ -924,51 +852,42 @@ source-map-support "^0.5.16" "@babel/runtime@^7.8.4": - version "7.11.2" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" - integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== + version "7.12.5" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" + integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== dependencies: regenerator-runtime "^0.13.4" -"@babel/template@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" - integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/parser" "^7.10.4" - "@babel/types" "^7.10.4" - -"@babel/template@^7.12.7": +"@babel/template@^7.10.4", "@babel/template@^7.12.7": version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" integrity sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow== dependencies: "@babel/code-frame" "^7.10.4" "@babel/parser" "^7.12.7" "@babel/types" "^7.12.7" -"@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.5", "@babel/traverse@^7.12.9": - version "7.12.9" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.9.tgz#fad26c972eabbc11350e0b695978de6cc8e8596f" - integrity sha512-iX9ajqnLdoU1s1nHt36JDI9KG4k+vmI8WgjK5d+aDTwQbL2fUnzedNedssA645Ede3PM2ma1n8Q4h2ohwXgMXw== +"@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" + integrity sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w== dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.5" - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/parser" "^7.12.7" - "@babel/types" "^7.12.7" + "@babel/code-frame" "^7.12.11" + "@babel/generator" "^7.12.11" + "@babel/helper-function-name" "^7.12.11" + "@babel/helper-split-export-declaration" "^7.12.11" + "@babel/parser" "^7.12.11" + "@babel/types" "^7.12.12" debug "^4.1.0" globals "^11.1.0" lodash "^4.17.19" -"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.12.1", "@babel/types@^7.12.5", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.4.4": - version "7.12.7" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.7.tgz#6039ff1e242640a29452c9ae572162ec9a8f5d13" - integrity sha512-MNyI92qZq6jrQkXvtIiykvl4WtoRrVV9MPn+ZfsoEENjiWcBQ3ZSHrkxnJWgWtLX3XXqX5hrSQ+X69wkmesXuQ== +"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.4.4": + version "7.12.12" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" + integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ== dependencies: - "@babel/helper-validator-identifier" "^7.10.4" + "@babel/helper-validator-identifier" "^7.12.11" lodash "^4.17.19" to-fast-properties "^2.0.0" @@ -996,21 +915,16 @@ integrity sha512-In1q0tIiqTYKAGe3KOHDcFDdZRFISyQeSeipeTHGfki23ebHRZcjxvqj5SSdBkw65D4VpSREMi0s9i5iJiMcTw== "@types/cheerio@^0.22.8": - version "0.22.21" - resolved "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.21.tgz#5e37887de309ba11b2e19a6e14cad7874b31a8a3" - integrity sha512-aGI3DfswwqgKPiEOTaiHV2ZPC9KEhprpgEbJnv0fZl3SGX0cGgEva1126dGrMC6AJM6v/aihlUgJn9M5DbDZ/Q== + version "0.22.23" + resolved "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.23.tgz#74bcfee9c5ee53f619711dca953a89fe5cfa4eb4" + integrity sha512-QfHLujVMlGqcS/ePSf3Oe5hK3H8wi/yN2JYuxSB1U10VvW1fO3K8C+mURQesFYS1Hn7lspOsTT75SKq/XtydQg== dependencies: "@types/node" "*" -"@types/color-name@^1.1.1": - version "1.1.1" - resolved "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" - integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== - "@types/node@*": - version "14.6.4" - resolved "https://registry.npmjs.org/@types/node/-/node-14.6.4.tgz#a145cc0bb14ef9c4777361b7bbafa5cf8e3acb5a" - integrity sha512-Wk7nG1JSaMfMpoMJDKUsWYugliB2Vy55pdjLpmLixeyMi7HizW2I/9QoxsPCkXl3dO+ZOVqPumKaDUv5zJu2uQ== + version "14.14.20" + resolved "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz#f7974863edd21d1f8a494a73e8e2b3658615c340" + integrity sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A== "@types/q@^1.5.1": version "1.5.4" @@ -1031,9 +945,9 @@ address@1.1.2, address@^1.0.1: integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== ajv@^6.12.3: - version "6.12.4" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" - integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== + version "6.12.6" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -1085,11 +999,10 @@ ansi-styles@^3.2.1: color-convert "^1.9.0" ansi-styles@^4.1.0: - version "4.2.1" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" - integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: - "@types/color-name" "^1.1.1" color-convert "^2.0.1" ansi-wrap@0.1.0: @@ -1106,9 +1019,9 @@ anymatch@^2.0.0: normalize-path "^2.1.1" arch@^2.1.0: - version "2.1.2" - resolved "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf" - integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ== + version "2.2.0" + resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" + integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== archive-type@^4.0.0: version "4.0.0" @@ -1212,7 +1125,7 @@ asynckit@^0.4.0: at-least-node@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== atob@^2.1.2: @@ -1221,9 +1134,9 @@ atob@^2.1.2: integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== autolinker@^3.11.0: - version "3.14.1" - resolved "https://registry.npmjs.org/autolinker/-/autolinker-3.14.1.tgz#6ae4b812b6eaf42d4d68138b9e67757cbf2bc1e4" - integrity sha512-yvsRHIaY51EYDml6MGlbqyJGfl4n7zezGYf+R7gvM8c5LNpRGc4SISkvgAswSS8SWxk/OrGCylKV9mJyVstz7w== + version "3.14.2" + resolved "https://registry.npmjs.org/autolinker/-/autolinker-3.14.2.tgz#71856274eb768fb7149039e24d3a2be2f5c55a63" + integrity sha512-VO66nXUCZFxTq7fVHAaiAkZNXRQ1l3IFi6D5P7DLoyIEAn2E8g7TWbyEgLlz1uW74LfWmu1A17IPWuPQyGuNVg== dependencies: tslib "^1.9.3" @@ -1253,9 +1166,9 @@ aws-sign2@~0.7.0: integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= aws4@^1.8.0: - version "1.10.1" - resolved "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz#e1e82e4f3e999e2cfd61b161280d16a111f86428" - integrity sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA== + version "1.11.0" + resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== babel-code-frame@^6.22.0: version "6.26.0" @@ -1283,10 +1196,10 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -base64-js@^1.0.2: - version "1.3.1" - resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" - integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== base@^0.11.1: version "0.11.2" @@ -1445,24 +1358,14 @@ browserslist@4.7.0: electron-to-chromium "^1.3.247" node-releases "^1.1.29" -browserslist@^4.0.0, browserslist@^4.12.0: - version "4.14.1" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.14.1.tgz#cb2b490ba881d45dc3039078c7ed04411eaf3fa3" - integrity sha512-zyBTIHydW37pnb63c7fHFXUG6EcqWOqoMdDx6cdyaDFriZ20EoVxcE95S54N+heRqY8m8IUgB5zYta/gCwSaaA== +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.0: + version "4.16.0" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.16.0.tgz#410277627500be3cb28a1bfe037586fbedf9488b" + integrity sha512-/j6k8R0p3nxOC6kx5JGAxsnhc9ixaWJfYc+TNTzxg6+ARaESAvQGV7h0uNOB4t+pLQJZWzcrMxXOxjgsCj3dqQ== dependencies: - caniuse-lite "^1.0.30001124" - electron-to-chromium "^1.3.562" - escalade "^3.0.2" - node-releases "^1.1.60" - -browserslist@^4.14.5, browserslist@^4.14.7: - version "4.15.0" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.15.0.tgz#3d48bbca6a3f378e86102ffd017d9a03f122bdb0" - integrity sha512-IJ1iysdMkGmjjYeRlDU8PQejVwxvVO5QOfXH7ylW31GO6LwNRSmm/SgRXtNsEXqMLl2e+2H5eEJ7sfynF8TCaQ== - dependencies: - caniuse-lite "^1.0.30001164" + caniuse-lite "^1.0.30001165" colorette "^1.2.1" - electron-to-chromium "^1.3.612" + electron-to-chromium "^1.3.621" escalade "^3.1.1" node-releases "^1.1.67" @@ -1495,12 +1398,12 @@ buffer-from@^1.0.0: integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== buffer@^5.2.1: - version "5.6.0" - resolved "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" - integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== + version "5.7.1" + resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== dependencies: - base64-js "^1.0.2" - ieee754 "^1.1.4" + base64-js "^1.3.1" + ieee754 "^1.1.13" bytes@1: version "1.0.0" @@ -1540,6 +1443,14 @@ cacheable-request@^2.1.1: normalize-url "2.0.1" responselike "1.0.2" +call-bind@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.0.tgz#24127054bb3f9bdcb4b1fb82418186072f77b8ce" + integrity sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.0" + call-me-maybe@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" @@ -1587,15 +1498,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001124: - version "1.0.30001124" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001124.tgz#5d9998190258e11630d674fc50ea8e579ae0ced2" - integrity sha512-zQW8V3CdND7GHRH6rxm6s59Ww4g/qGWTheoboW9nfeMg7sUoopIfKCcNZUjwYRCOrvereh3kwDpZj4VLQ7zGtA== - -caniuse-lite@^1.0.30001164: - version "1.0.30001164" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001164.tgz#5bbfd64ca605d43132f13cc7fdabb17c3036bfdc" - integrity sha512-G+A/tkf4bu0dSp9+duNiXc7bGds35DioCyC6vgK2m/rjA4Krpy5WeZgZyfH2f0wj2kI6yAWWucyap6oOwmY1mg== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000989, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001165: + version "1.0.30001173" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001173.tgz#3c47bbe3cd6d7a9eda7f50ac016d158005569f56" + integrity sha512-R3aqmjrICdGCTAnSXtNyvWYMK3YtV5jwudbq0T7nN9k4kmE4CBuwPqyJ+KBzepSTh0huivV2gLbSMEzTTmfeYw== caseless@~0.12.0: version "0.12.0" @@ -1775,21 +1681,21 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.5.2: - version "1.5.3" - resolved "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" - integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== +color-string@^1.5.4: + version "1.5.4" + resolved "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6" + integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw== dependencies: color-name "^1.0.0" simple-swizzle "^0.2.2" color@^3.0.0: - version "3.1.2" - resolved "https://registry.npmjs.org/color/-/color-3.1.2.tgz#68148e7f85d41ad7649c5fa8c8106f098d229e10" - integrity sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg== + version "3.1.3" + resolved "https://registry.npmjs.org/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" + integrity sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ== dependencies: color-convert "^1.9.1" - color-string "^1.5.2" + color-string "^1.5.4" colorette@^1.2.1: version "1.2.1" @@ -1902,18 +1808,18 @@ copy-descriptor@^0.1.0: resolved "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -core-js-compat@^3.7.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.8.0.tgz#3248c6826f4006793bd637db608bca6e4cd688b1" - integrity sha512-o9QKelQSxQMYWHXc/Gc4L8bx/4F7TTraE5rhuN8I7mKBt5dBIUpXpIR3omv70ebr8ST5R3PqbDQr+ZI3+Tt1FQ== +core-js-compat@^3.8.0: + version "3.8.2" + resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.8.2.tgz#3717f51f6c3d2ebba8cbf27619b57160029d1d4c" + integrity sha512-LO8uL9lOIyRRrQmZxHZFl1RV+ZbcsAkFWTktn5SmH40WgLtSNYN4m4W2v9ONT147PxBY/XrRhrWq8TlvObyUjQ== dependencies: - browserslist "^4.14.7" + browserslist "^4.16.0" semver "7.0.0" core-js@^2.6.5: - version "2.6.11" - resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" - integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== + version "2.6.12" + resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" + integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -2005,12 +1911,12 @@ css-tree@1.0.0-alpha.37: mdn-data "2.0.4" source-map "^0.6.1" -css-tree@1.0.0-alpha.39: - version "1.0.0-alpha.39" - resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.39.tgz#2bff3ffe1bb3f776cf7eefd91ee5cba77a149eeb" - integrity sha512-7UvkEYgBAHRG9Nt980lYxjsTrCyHFN53ky3wVsDkiMdVqylqRt+Zc+jm5qw7/qyOvN2dHSYtX0e4MbCCExSvnA== +css-tree@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.2.tgz#9ae393b5dafd7dae8a622475caec78d3d8fbd7b5" + integrity sha512-wCoWush5Aeo48GLhfHPbmvZs59Z+M7k5+B1xDnXbdWNcEF423DoFdqSWE0PM5aNk5nI5cp1q7ms36zGApY/sKQ== dependencies: - mdn-data "2.0.6" + mdn-data "2.0.14" source-map "^0.6.1" css-what@2.1: @@ -2019,9 +1925,9 @@ css-what@2.1: integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== css-what@^3.2.1: - version "3.3.0" - resolved "https://registry.npmjs.org/css-what/-/css-what-3.3.0.tgz#10fec696a9ece2e591ac772d759aacabac38cd39" - integrity sha512-pv9JPyatiPaQ6pf4OvD/dbfm0o5LviWmwxNWzblYf/1u9QZd0ihV+PMwy5jdQWQ3349kZmKEx9WXuSka2dM4cg== + version "3.4.2" + resolved "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" + integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== cssesc@^3.0.0: version "3.0.0" @@ -2097,11 +2003,11 @@ cssnano@^4.1.10: postcss "^7.0.0" csso@^4.0.2: - version "4.0.3" - resolved "https://registry.npmjs.org/csso/-/csso-4.0.3.tgz#0d9985dc852c7cc2b2cacfbbe1079014d1a8e903" - integrity sha512-NL3spysxUkcrOgnpsT4Xdl2aiEiBG6bXswAABQVHcMrfjjBisFOKwLDOmf4wf32aPdcJws1zds2B0Rg+jqMyHQ== + version "4.2.0" + resolved "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== dependencies: - css-tree "1.0.0-alpha.39" + css-tree "^1.1.2" currently-unhandled@^0.4.1: version "0.4.1" @@ -2124,24 +2030,17 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0: dependencies: ms "2.0.0" -debug@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87" - integrity sha512-heNPJUJIqC+xB6ayLAMHaIrmN9HKa7aQO8MGqKpvCA+uJYVcvR6l5kgdrhRuwPFHU7P5/A1w0BjByPHwpfTDKg== +debug@4.3.1, debug@^4.1.0: + version "4.3.1" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== dependencies: - ms "^2.1.1" + ms "2.1.2" debug@^3.1.0, debug@^3.1.1, debug@^3.2.5: - version "3.2.6" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - -debug@^4.1.0: - version "4.1.1" - resolved "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: ms "^2.1.1" @@ -2220,7 +2119,7 @@ deep-is@^0.1.3: resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= -define-properties@^1.1.2, define-properties@^1.1.3: +define-properties@^1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== @@ -2290,9 +2189,9 @@ dir-glob@2.0.0: arrify "^1.0.1" path-type "^3.0.0" -docusaurus@^2.0.0-alpha.378053ac5: +docusaurus@^2.0.0-alpha.70: version "2.0.0-alpha.378053ac5" - resolved "https://registry.yarnpkg.com/docusaurus/-/docusaurus-2.0.0-alpha.378053ac5.tgz#9ca31969ef6eb8958692948ae1fd1d4e0f452f44" + resolved "https://registry.npmjs.org/docusaurus/-/docusaurus-2.0.0-alpha.378053ac5.tgz#9ca31969ef6eb8958692948ae1fd1d4e0f452f44" integrity sha512-+NM1NrJKYcmHYiMQ/4b5ew9QDbLW3QI/ii2lCs/gpEZhtU0FVnl1RLC3FUO41xgOhth3A8M5G5ChzlPuNzxjug== dependencies: "@babel/core" "^7.12.3" @@ -2364,9 +2263,9 @@ domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== domelementtype@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d" - integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ== + version "2.1.0" + resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" + integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== domhandler@^2.3.0: version "2.4.2" @@ -2392,9 +2291,9 @@ domutils@^1.5.1, domutils@^1.7.0: domelementtype "1" dot-prop@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb" - integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A== + version "5.3.0" + resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" + integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== dependencies: is-obj "^2.0.0" @@ -2456,15 +2355,10 @@ ee-first@1.1.1: resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.562: - version "1.3.562" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.562.tgz#79c20277ee1c8d0173a22af00e38433b752bc70f" - integrity sha512-WhRe6liQ2q/w1MZc8mD8INkenHivuHdrr4r5EQHNomy3NJux+incP6M6lDMd0paShP3MD0WGe5R1TWmEClf+Bg== - -electron-to-chromium@^1.3.612: - version "1.3.614" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.614.tgz#ff359e8d2249e2ce859a4c2bc34c22bd2e2eb0a2" - integrity sha512-JMDl46mg4G+n6q/hAJkwy9eMTj5FJjsE+8f/irAGRMLM4yeRVbMuRrdZrbbGGOrGVcZc4vJPjUpEUWNb/fA6hg== +electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.621: + version "1.3.634" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.634.tgz#82ea400f520f739c4f6ff00c1f7524827a917d25" + integrity sha512-QPrWNYeE/A0xRvl/QP3E0nkaEvYUvH3gM04ZWYtIa6QlSpEetRlRI1xvQ7hiMIySHHEV+mwDSX8Kj4YZY6ZQAw== "emoji-regex@>=6.0.0 <=6.1.1": version "6.1.1" @@ -2494,9 +2388,9 @@ entities@^1.1.1, entities@~1.1.1: integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== entities@^2.0.0: - version "2.0.3" - resolved "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" - integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== + version "2.1.0" + resolved "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" + integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== error-ex@^1.2.0, error-ex@^1.3.1: version "1.3.2" @@ -2512,20 +2406,38 @@ error@^7.0.0: dependencies: string-template "~0.2.1" -es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5: - version "1.17.6" - resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" - integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== +es-abstract@^1.17.2: + version "1.17.7" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" + integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== dependencies: es-to-primitive "^1.2.1" function-bind "^1.1.1" has "^1.0.3" has-symbols "^1.0.1" - is-callable "^1.2.0" - is-regex "^1.1.0" - object-inspect "^1.7.0" + is-callable "^1.2.2" + is-regex "^1.1.1" + object-inspect "^1.8.0" object-keys "^1.1.1" - object.assign "^4.1.0" + object.assign "^4.1.1" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + +es-abstract@^1.18.0-next.1: + version "1.18.0-next.1" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" + integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.2" + is-negative-zero "^2.0.0" + is-regex "^1.1.1" + object-inspect "^1.8.0" + object-keys "^1.1.1" + object.assign "^4.1.1" string.prototype.trimend "^1.0.1" string.prototype.trimstart "^1.0.1" @@ -2538,14 +2450,9 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -escalade@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/escalade/-/escalade-3.0.2.tgz#6a580d70edb87880f22b4c91d0d56078df6962c4" - integrity sha512-gPYAU37hYCUhW5euPeR+Y74F7BL+IBsV93j5cvGriSaD1aG6MGsqsV1yamRdrWrb2j3aiZvb0X+UBOWpx3JWtQ== - escalade@^3.1.1: version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== escape-html@~1.0.3: @@ -2798,7 +2705,7 @@ fd-slicer@~1.1.0: feed@^4.2.1: version "4.2.1" - resolved "https://registry.yarnpkg.com/feed/-/feed-4.2.1.tgz#b246ef891051c7dbf088ca203341d9fb0444baee" + resolved "https://registry.npmjs.org/feed/-/feed-4.2.1.tgz#b246ef891051c7dbf088ca203341d9fb0444baee" integrity sha512-l28KKcK1J/u3iq5dRDmmoB2p7dtBfACC2NqJh4dI2kFptxH0asfjmOfcxqh5Sv8suAlVa73gZJ4REY5RrafVvg== dependencies: xml-js "^1.6.11" @@ -3009,7 +2916,7 @@ fs-constants@^1.0.0: fs-extra@^9.0.1: version "9.0.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== dependencies: at-least-node "^1.0.0" @@ -3043,9 +2950,18 @@ gaze@^1.1.3: globule "^1.0.0" gensync@^1.0.0-beta.1: - version "1.0.0-beta.1" - resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" - integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg== + version "1.0.0-beta.2" + resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-intrinsic@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.2.tgz#6820da226e50b24894e08859469dc68361545d49" + integrity sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" get-proxy@^2.0.0: version "2.1.0" @@ -3103,7 +3019,7 @@ gifsicle@^4.0.0: github-slugger@^1.3.0: version "1.3.0" - resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" + resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" integrity sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q== dependencies: emoji-regex ">=6.0.0 <=6.1.1" @@ -3294,7 +3210,7 @@ has-symbol-support-x@^1.4.1: resolved "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455" integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw== -has-symbols@^1.0.0, has-symbols@^1.0.1: +has-symbols@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== @@ -3350,9 +3266,9 @@ hex-color-regex@^1.1.0: integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== highlight.js@^9.16.2: - version "9.18.3" - resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.18.3.tgz#a1a0a2028d5e3149e2380f8a865ee8516703d634" - integrity sha512-zBZAmhSupHIl5sITeMqIJnYCDfAEc3Gdkqj65wC1lpI468MMQeeQkhcIAvk+RylAkxrCcI9xy9piHiXeQ1BdzQ== + version "9.18.5" + resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.18.5.tgz#d18a359867f378c138d6819edfc2a8acd5f29825" + integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA== hosted-git-info@^2.1.4: version "2.8.8" @@ -3414,9 +3330,9 @@ http-errors@~1.7.2: toidentifier "1.0.0" http-parser-js@>=0.5.1: - version "0.5.2" - resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.2.tgz#da2e31d237b393aae72ace43882dd7e270a8ff77" - integrity sha512-opCO9ASqg5Wy2FNo7A0sxy71yGbbkJJXLdgMK04Tcypw9jr2MgWbyubb0+WdmDmGnFflO7fRbqbaihh/ENDlRQ== + version "0.5.3" + resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.3.tgz#01d2709c79d41698bb01d4decc5e9da4e4a033d9" + integrity sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg== http-signature@~1.2.0: version "1.2.0" @@ -3434,10 +3350,10 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -ieee754@^1.1.4: - version "1.1.13" - resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" - integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== ignore@^3.3.5: version "3.3.10" @@ -3540,9 +3456,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.4, ini@^1.3.5: - version "1.3.5" - resolved "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.8" + resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== inquirer@6.5.0: version "6.5.0" @@ -3576,10 +3492,10 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" -ip-regex@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" - integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= +ip-regex@^4.1.0: + version "4.2.0" + resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-4.2.0.tgz#a03f5eb661d9a154e3973a03de8b23dd0ad6892e" + integrity sha512-n5cDDeTWWRwK1EBoWwRti+8nP4NbytBBY0pldmnIkq6Z55KNFmWofh4rl9dPZpj+U/nVq7gweR3ylrvMt4YZ5A== ipaddr.js@1.9.1: version "1.9.1" @@ -3627,10 +3543,10 @@ is-buffer@^1.1.5: resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-callable@^1.1.4, is-callable@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb" - integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw== +is-callable@^1.1.4, is-callable@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" + integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== is-color-stop@^1.0.0: version "1.1.0" @@ -3644,6 +3560,13 @@ is-color-stop@^1.0.0: rgb-regex "^1.0.1" rgba-regex "^1.0.0" +is-core-module@^2.1.0: + version "2.2.0" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" + integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -3744,6 +3667,11 @@ is-natural-number@^4.0.1: resolved "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" integrity sha1-q5124dtM7VHjXeDHLr7PCfc0zeg= +is-negative-zero@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== + is-number@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" @@ -3769,9 +3697,9 @@ is-obj@^2.0.0: integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== is-object@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470" - integrity sha1-iVJojF7C/9awPsyF52ngKQMINHA= + version "1.0.2" + resolved "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz#a56552e1c665c9e950b4a025461da87e72f86fcf" + integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA== is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: version "1.1.0" @@ -3790,7 +3718,7 @@ is-png@^1.0.0: resolved "https://registry.npmjs.org/is-png/-/is-png-1.1.0.tgz#d574b12bf275c0350455570b0e5b57ab062077ce" integrity sha1-1XSxK/J1wDUEVVcLDltXqwYgd84= -is-regex@^1.1.0: +is-regex@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== @@ -3843,7 +3771,7 @@ is-typedarray@~1.0.0: resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= -is-url@^1.2.2: +is-url@^1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww== @@ -3863,14 +3791,14 @@ is-wsl@^1.1.0: resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= -is2@2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/is2/-/is2-2.0.1.tgz#8ac355644840921ce435d94f05d3a94634d3481a" - integrity sha512-+WaJvnaA7aJySz2q/8sLjMb2Mw14KTplHmSwcSpZ/fWJPkUmqw3YTzSWbPJ7OAwRvdYTWF2Wg+yYJ1AdP5Z8CA== +is2@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/is2/-/is2-2.0.6.tgz#094f887248b49ba7ce278f8c39f85a70927bb5de" + integrity sha512-+Z62OHOjA6k2sUDOKXoZI3EXv7Fb1K52jpTBLbkfx62bcUeSsrTBLhEquCRDKTx0XE5XbHcG/S2vrtE3lnEDsQ== dependencies: deep-is "^0.1.3" - ip-regex "^2.1.0" - is-url "^1.2.2" + ip-regex "^4.1.0" + is-url "^1.2.4" isarray@1.0.0, isarray@~1.0.0: version "1.0.0" @@ -3928,7 +3856,7 @@ js-tokens@^3.0.2: js-yaml@^3.13.1, js-yaml@^3.8.1: version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== dependencies: argparse "^1.0.7" @@ -4002,7 +3930,7 @@ json5@^2.1.2: jsonfile@^6.0.1: version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== dependencies: universalify "^2.0.0" @@ -4334,16 +4262,16 @@ math-random@^1.0.1: resolved "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + mdn-data@2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" integrity sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA== -mdn-data@2.0.6: - version "2.0.6" - resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" - integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== - media-typer@0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -4404,17 +4332,17 @@ micromatch@^3.1.10, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" -mime-db@1.44.0, mime-db@^1.28.0: - version "1.44.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" - integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== +mime-db@1.45.0, mime-db@^1.28.0: + version "1.45.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea" + integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w== mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24: - version "2.1.27" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" - integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + version "2.1.28" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.28.tgz#1160c4757eab2c5363888e005273ecf79d2a0ecd" + integrity sha512-0TO2yJ5YHYr7M2zzT7gDU1tbwHxEUWBCLt0lscSNpcdAfFyJOVEpRYNS7EXVcTLNj/25QO8gulHC5JtTzSE2UQ== dependencies: - mime-db "1.44.0" + mime-db "1.45.0" mime@1.6.0: version "1.6.0" @@ -4468,20 +4396,25 @@ ms@2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@^2.1.1: +ms@2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + mute-stream@0.0.7: version "0.0.7" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= nan@^2.12.1: - version "2.14.1" - resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" - integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== + version "2.14.2" + resolved "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" + integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== nanomatch@^1.2.9: version "1.2.13" @@ -4515,15 +4448,10 @@ node-modules-regexp@^1.0.0: resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= -node-releases@^1.1.29, node-releases@^1.1.60: - version "1.1.60" - resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" - integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== - -node-releases@^1.1.67: - version "1.1.67" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.67.tgz#28ebfcccd0baa6aad8e8d4d8fe4cbc49ae239c12" - integrity sha512-V5QF9noGFl3EymEwUYzO+3NTDpGfQB4ve6Qfnzf3UNydMhjQRVPR1DZTuvWiLzaFJYw2fmDwAfnRNEVb64hSIg== +node-releases@^1.1.29, node-releases@^1.1.67: + version "1.1.69" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.69.tgz#3149dbde53b781610cd8b486d62d86e26c3725f6" + integrity sha512-DGIjo79VDEyAnRlfSqYTsy+yoHd2IOjJiKUozD2MV2D85Vso6Bug56mb9tT/fY5Urt0iqk01H7x+llAruDR2zA== normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.5.0" @@ -4612,12 +4540,12 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-inspect@^1.7.0: - version "1.8.0" - resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" - integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== +object-inspect@^1.8.0: + version "1.9.0" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" + integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== -object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: +object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== @@ -4629,23 +4557,24 @@ object-visit@^1.0.0: dependencies: isobject "^3.0.0" -object.assign@^4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" - integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== +object.assign@^4.1.0, object.assign@^4.1.1: + version "4.1.2" + resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" + integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== dependencies: - define-properties "^1.1.2" - function-bind "^1.1.1" - has-symbols "^1.0.0" - object-keys "^1.0.11" + call-bind "^1.0.0" + define-properties "^1.1.3" + has-symbols "^1.0.1" + object-keys "^1.1.1" object.getownpropertydescriptors@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" - integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg== + version "2.1.1" + resolved "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.1.tgz#0dfda8d108074d9c563e80490c883b6661091544" + integrity sha512-6DtXgZ/lIZ9hqx4GtZETobXLR/ZLaa0aqV0kzbn80Rf8Z2e/XFnhA0I7p07N2wH8bBBltr2xQPi6sbKWAY2Eng== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" + es-abstract "^1.18.0-next.1" object.pick@^1.2.0, object.pick@^1.3.0: version "1.3.0" @@ -4655,13 +4584,13 @@ object.pick@^1.2.0, object.pick@^1.3.0: isobject "^3.0.1" object.values@^1.1.0: - version "1.1.1" - resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e" - integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA== + version "1.1.2" + resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.2.tgz#7a2015e06fcb0f546bd652486ce8583a4731c731" + integrity sha512-MYC0jvJopr8EK6dPBiO8Nb9mvjdypOachO5REGk6MXzujbBrAisKo3HmdEI6kZDL6fC31Mwee/5YbtMebixeag== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" + es-abstract "^1.18.0-next.1" has "^1.0.3" on-finished@~2.3.0: @@ -4961,7 +4890,7 @@ pkg-up@2.0.0: portfinder@^1.0.28: version "1.0.28" - resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" + resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778" integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA== dependencies: async "^2.6.2" @@ -4974,9 +4903,9 @@ posix-character-classes@^0.1.0: integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= postcss-calc@^7.0.1: - version "7.0.4" - resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.4.tgz#5e177ddb417341e6d4a193c5d9fd8ada79094f8b" - integrity sha512-0I79VRAd1UTkaHzY9w83P39YGO/M3bG7/tNLrHGEunBolfoGM0hSjrGvjoeaj0JE/zIw5GsI2KZ0UwDJqv5hjw== + version "7.0.5" + resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.5.tgz#f8a6e99f12e619c2ebc23cf6c486fdc15860933e" + integrity sha512-1tKHutbGtLtEZF6PT4JSihCHfIVldU72mZ8SdZHIYriIZ9fh9k9aWSppaT8rHsyI3dX+KSR+W+Ix9BMY3AODrg== dependencies: postcss "^7.0.27" postcss-selector-parser "^6.0.2" @@ -5211,13 +5140,14 @@ postcss-selector-parser@^3.0.0: uniq "^1.0.1" postcss-selector-parser@^6.0.2: - version "6.0.2" - resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" - integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg== + version "6.0.4" + resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz#56075a1380a04604c38b063ea7767a129af5c2b3" + integrity sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw== dependencies: cssesc "^3.0.0" indexes-of "^1.0.1" uniq "^1.0.1" + util-deprecate "^1.0.2" postcss-svgo@^4.0.2: version "4.0.2" @@ -5249,9 +5179,9 @@ postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.23, postcss@^7.0.27, postcss@^7.0.32: - version "7.0.32" - resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" - integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== + version "7.0.35" + resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" + integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== dependencies: chalk "^2.4.2" source-map "^0.6.1" @@ -5273,9 +5203,9 @@ prettier@^2.2.1: integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== prismjs@^1.22.0: - version "1.22.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.22.0.tgz#73c3400afc58a823dd7eed023f8e1ce9fd8977fa" - integrity sha512-lLJ/Wt9yy0AiSYBf212kK3mM5L8ycwlyTlSxHBAneXLR0nzFMlZ5y7riFPF3E33zXOF2IH95xdY5jIyZbM9z/w== + version "1.23.0" + resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33" + integrity sha512-c29LVsqOaLbBHuIbsTxaKENh1N2EQBOHaWv7gkHN4dgRbxSREqDnDbtFJYdpPauS4YCplMSNCABQ6Eeor69bAA== optionalDependencies: clipboard "^2.0.0" @@ -5427,9 +5357,9 @@ react-dev-utils@^9.1.0: text-table "0.2.0" react-dom@^16.8.4: - version "16.13.1" - resolved "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" - integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== + version "16.14.0" + resolved "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" + integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -5437,9 +5367,9 @@ react-dom@^16.8.4: scheduler "^0.19.1" react-error-overlay@^6.0.3: - version "6.0.7" - resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108" - integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA== + version "6.0.8" + resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de" + integrity sha512-HvPuUQnLp5H7TouGq3kzBeioJmXms1wHy9EGjz2OURWBp4qZO6AfGEcnxts1D/CbwPLRAgTMPCEgYhA3sEM4vw== react-is@^16.8.1: version "16.13.1" @@ -5447,9 +5377,9 @@ react-is@^16.8.1: integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== react@^16.8.4: - version "16.13.1" - resolved "https://registry.npmjs.org/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" - integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== + version "16.14.0" + resolved "https://registry.npmjs.org/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -5533,9 +5463,9 @@ regenerate-unicode-properties@^8.2.0: regenerate "^1.4.0" regenerate@^1.4.0: - version "1.4.1" - resolved "https://registry.npmjs.org/regenerate/-/regenerate-1.4.1.tgz#cad92ad8e6b591773485fbe05a485caf4f457e6f" - integrity sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A== + version "1.4.2" + resolved "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" + integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== regenerator-runtime@^0.13.4: version "0.13.7" @@ -5557,21 +5487,9 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -regexpu-core@^4.7.0: - version "4.7.0" - resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.0.tgz#fcbf458c50431b0bb7b45d6967b8192d91f3d938" - integrity sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ== - dependencies: - regenerate "^1.4.0" - regenerate-unicode-properties "^8.2.0" - regjsgen "^0.5.1" - regjsparser "^0.6.4" - unicode-match-property-ecmascript "^1.0.4" - unicode-match-property-value-ecmascript "^1.2.0" - regexpu-core@^4.7.1: version "4.7.1" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" + resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6" integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ== dependencies: regenerate "^1.4.0" @@ -5677,11 +5595,12 @@ resolve-url@^0.2.1: resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.1.6, resolve@^1.10.0, resolve@^1.3.2: - version "1.17.0" - resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" - integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== +resolve@^1.1.6, resolve@^1.10.0: + version "1.19.0" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" + integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== dependencies: + is-core-module "^2.1.0" path-parse "^1.0.6" responselike@1.0.2: @@ -5727,9 +5646,9 @@ run-async@^2.2.0: integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== rxjs@^6.4.0: - version "6.6.2" - resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2" - integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg== + version "6.6.3" + resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" + integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== dependencies: tslib "^1.9.0" @@ -6030,9 +5949,9 @@ spdx-expression-parse@^3.0.0: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.5" - resolved "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654" - integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== + version "3.0.7" + resolved "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" + integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" @@ -6107,20 +6026,20 @@ string-width@^2.1.0: strip-ansi "^4.0.0" string.prototype.trimend@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" - integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== + version "1.0.3" + resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.3.tgz#a22bd53cca5c7cf44d7c9d5c732118873d6cd18b" + integrity sha512-ayH0pB+uf0U28CtjlLvL7NaohvR1amUvVZk+y3DYb0Ey2PUV5zPkkKy9+U1ndVEIXO8hNg18eIv9Jntbii+dKw== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.5" string.prototype.trimstart@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" - integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== + version "1.0.3" + resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.3.tgz#9b4cb590e123bb36564401d59824298de50fd5aa" + integrity sha512-oBIBUy5lea5tt0ovtOFiEQaBkoBBkyJhZXzJYrSmDo5IUUqbOPvVezuRs/agBIdZ2p2Eo1FD6bD9USyBLfl3xg== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.5" string_decoder@0.10: version "0.10.31" @@ -6273,12 +6192,12 @@ tar-stream@^1.5.2: xtend "^4.0.0" tcp-port-used@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.1.tgz#46061078e2d38c73979a2c2c12b5a674e6689d70" - integrity sha512-rwi5xJeU6utXoEIiMvVBMc9eJ2/ofzB+7nLOdnZuFTmNCLqRiQh2sMG9MqCxHU/69VC/Fwp5dV9306Qd54ll1Q== + version "1.0.2" + resolved "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.2.tgz#9652b7436eb1f4cfae111c79b558a25769f6faea" + integrity sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA== dependencies: - debug "4.1.0" - is2 "2.0.1" + debug "4.3.1" + is2 "^2.0.6" temp-dir@^1.0.0: version "1.0.0" @@ -6433,9 +6352,9 @@ truncate-html@^1.0.3: cheerio "0.22.0" tslib@^1.9.0, tslib@^1.9.3: - version "1.13.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" - integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== + version "1.14.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== tunnel-agent@^0.6.0: version "0.6.0" @@ -6515,12 +6434,12 @@ uniqs@^2.0.0: universalify@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" + resolved "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== universalify@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== unpipe@1.0.0, unpipe@~1.0.0: @@ -6590,7 +6509,7 @@ use@^3.1.0: resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -util-deprecate@^1.0.1, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= diff --git a/mkdocs.yml b/mkdocs.yml index 109be46a58..a303fd9978 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,9 @@ nav: - Create your own Templater: 'features/software-templates/extending/create-your-own-templater.md' - Create your own Publisher: 'features/software-templates/extending/create-your-own-publisher.md' - Create your own Preparer: 'features/software-templates/extending/create-your-own-preparer.md' + - Backstage Search: + - Overview: 'features/search/README.md' + - Architecture: 'features/search/architecture.md' - TechDocs: - Overview: 'features/techdocs/README.md' - Getting Started: 'features/techdocs/getting-started.md' @@ -56,14 +59,18 @@ nav: - Creating and Publishing Documentation: 'features/techdocs/creating-and-publishing.md' - Configuration: 'features/techdocs/configuration.md' - Using Cloud Storage: 'features/techdocs/using-cloud-storage.md' + - HOW TO guides: 'features/techdocs/how-to-guides.md' - Troubleshooting: 'features/techdocs/troubleshooting.md' - FAQ: 'features/techdocs/FAQ.md' + - Kubernetes: + - Overview: 'features/kubernetes/index.md' - Plugins: - Overview: 'plugins/index.md' - Existing plugins: 'plugins/existing-plugins.md' - Creating a new plugin: 'plugins/create-a-plugin.md' - Developing a plugin: 'plugins/plugin-development.md' - Structure of a plugin: 'plugins/structure-of-a-plugin.md' + - Composability System Migration: 'plugins/composability.md' - Backends and APIs: - Proxying: 'plugins/proxying.md' - Backstage backend plugin: 'plugins/backend-plugin.md' @@ -73,6 +80,7 @@ nav: - Publishing: - Open source and npm: 'plugins/publishing.md' - Private/internal (non-open source): 'plugins/publish-private.md' + - Observability: 'plugins/observability.md' - Configuration: - Overview: 'conf/index.md' - Reading Configuration: 'conf/reading.md' @@ -111,8 +119,11 @@ nav: - 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' + - ADR010 - Luxon Date Library: 'architecture-decisions/adr010-luxon-date-library.md' + - ADR011 - Plugin Package Structure: 'architecture-decisions/adr011-plugin-package-structure.md' - Contribute: '../CONTRIBUTING.md' - Support: - 'support/support.md' - 'support/project-structure.md' + - Glossary: glossary.md - FAQ: FAQ.md diff --git a/package.json b/package.json index 8376915729..ad0ce2cd57 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "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 --scope backstage --no-private", "remove-plugin": "backstage-cli remove-plugin", @@ -38,12 +37,13 @@ }, "resolutions": { "**/@roadiehq/**/@backstage/core": "*", + "**/@roadiehq/**/@backstage/plugin-catalog": "*", "**/@roadiehq/**/@backstage/catalog-model": "*" }, "version": "1.0.0", "devDependencies": { - "@changesets/cli": "^2.11.0", - "@octokit/openapi-types": "^2.0.0", + "@changesets/cli": "^2.14.0", + "@octokit/openapi-types": "^2.2.0", "@spotify/eslint-config-oss": "^1.0.1", "@spotify/prettier-config": "^9.0.0", "command-exists": "^1.2.9", @@ -76,7 +76,7 @@ }, "jest": { "transformModules": [ - "@kyma-project/asyncapi-react" + "@asyncapi/react-component" ] } } diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 3eaf5ad111..fcd9b795a4 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,180 @@ # example-app +## 0.2.14 + +### Patch Changes + +- 9d6ef14bc: Migrated to new composability API, exporting the plugin instance as `rollbarPlugin`, the entity page content as `EntityRollbarContent`, and entity conditional as `isRollbarAvailable`. Updated the `EntityPage` for the `example-app` to include a composite `ErrorsSwitcher` component that works with both `Sentry` & `Rollbar`. Also removed the unused and undocumented `RollbarHome` related components. +- Updated dependencies [ceef4dd89] +- Updated dependencies [720149854] +- Updated dependencies [19172f5a9] +- Updated dependencies [4c6a6dddd] +- Updated dependencies [398e1f83e] +- Updated dependencies [87b189d00] +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [b712841d6] +- Updated dependencies [a5628df40] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [bc5082a00] +- Updated dependencies [6e612ce25] +- Updated dependencies [e44925723] +- Updated dependencies [b37501a3d] +- Updated dependencies [9d6ef14bc] +- Updated dependencies [025e122c3] +- Updated dependencies [e9aab60c7] +- Updated dependencies [21e624ba9] +- Updated dependencies [0269f4fd9] +- Updated dependencies [19fe61c27] +- Updated dependencies [da9f53c60] +- Updated dependencies [a08c4b0b0] +- Updated dependencies [bc5082a00] +- Updated dependencies [bc5082a00] +- Updated dependencies [b37501a3d] +- Updated dependencies [90c8f20b9] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [8dfdec613] +- Updated dependencies [54c7d02f7] +- Updated dependencies [de98c32ed] +- Updated dependencies [806929fe2] +- Updated dependencies [019fe39a0] +- Updated dependencies [019fe39a0] +- Updated dependencies [11cb5ef94] + - @backstage/plugin-catalog-import@0.3.7 + - @backstage/plugin-scaffolder@0.4.2 + - @backstage/plugin-cost-insights@0.8.0 + - @backstage/cli@0.6.0 + - @backstage/plugin-graphiql@0.2.7 + - @backstage/core@0.6.0 + - @backstage/plugin-api-docs@0.4.4 + - @backstage/plugin-catalog@0.3.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/plugin-org@0.3.5 + - @backstage/theme@0.2.3 + - @backstage/plugin-lighthouse@0.2.9 + - @backstage/plugin-techdocs@0.5.5 + - @backstage/plugin-user-settings@0.2.5 + - @backstage/catalog-model@0.7.1 + - @backstage/plugin-rollbar@0.2.9 + - @backstage/plugin-gcp-projects@0.2.4 + - @backstage/plugin-tech-radar@0.3.4 + - @backstage/plugin-welcome@0.2.5 + - @backstage/plugin-explore@0.2.4 + - @backstage/plugin-circleci@0.2.7 + - @backstage/plugin-cloudbuild@0.2.8 + - @backstage/plugin-github-actions@0.3.1 + - @backstage/plugin-jenkins@0.3.8 + - @backstage/plugin-kafka@0.2.1 + - @backstage/plugin-register-component@0.2.8 + - @backstage/plugin-search@0.2.7 + - @backstage/plugin-sentry@0.3.4 + - @backstage/plugin-gitops-profiles@0.2.4 + - @backstage/plugin-kubernetes@0.3.8 + - @backstage/plugin-newrelic@0.2.4 + - @backstage/plugin-pagerduty@0.2.7 + +## 0.2.13 + +### Patch Changes + +- Updated dependencies [681111228] +- Updated dependencies [12a56cdfe] +- Updated dependencies [8b7ef9f8b] +- Updated dependencies [fac91bcc5] +- Updated dependencies [9dd057662] +- Updated dependencies [234e7d985] +- Updated dependencies [ef7957be4] +- Updated dependencies [0b1182346] +- Updated dependencies [a6e3b9596] + - @backstage/plugin-kubernetes@0.3.7 + - @backstage/cli@0.5.0 + - @backstage/plugin-cost-insights@0.6.0 + - @backstage/plugin-catalog@0.2.14 + - @backstage/plugin-catalog-import@0.3.6 + - @backstage/plugin-scaffolder@0.4.1 + - @backstage/plugin-kafka@0.2.0 + +## 0.2.12 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [46bba09ea] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [8c2437c15] +- Updated dependencies [2b514d532] +- Updated dependencies [33846acfc] +- Updated dependencies [b604a9d41] +- Updated dependencies [d014185db] +- Updated dependencies [a187b8ad0] +- Updated dependencies [8855f61f6] +- Updated dependencies [ed6baab66] +- Updated dependencies [f04db53d7] +- Updated dependencies [a5e27d5c1] +- Updated dependencies [debf359b5] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/plugin-github-actions@0.3.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + - @backstage/plugin-cost-insights@0.5.7 + - @backstage/plugin-catalog-import@0.3.5 + - @backstage/cli@0.4.7 + - @backstage/plugin-kubernetes@0.3.6 + - @backstage/plugin-api-docs@0.4.3 + - @backstage/plugin-scaffolder@0.4.0 + - @backstage/plugin-techdocs@0.5.4 + - @backstage/plugin-lighthouse@0.2.8 + - @backstage/plugin-circleci@0.2.6 + - @backstage/plugin-cloudbuild@0.2.7 + - @backstage/plugin-jenkins@0.3.6 + - @backstage/plugin-kafka@0.1.1 + - @backstage/plugin-org@0.3.4 + - @backstage/plugin-pagerduty@0.2.6 + - @backstage/plugin-register-component@0.2.7 + - @backstage/plugin-rollbar@0.2.8 + - @backstage/plugin-search@0.2.6 + - @backstage/plugin-sentry@0.3.3 + - @backstage/plugin-explore@0.2.3 + - @backstage/plugin-gcp-projects@0.2.3 + - @backstage/plugin-gitops-profiles@0.2.3 + - @backstage/plugin-graphiql@0.2.6 + - @backstage/plugin-newrelic@0.2.3 + - @backstage/plugin-tech-radar@0.3.3 + - @backstage/plugin-user-settings@0.2.4 + - @backstage/plugin-welcome@0.2.4 + +## 0.2.9 + +### Patch Changes + +- Updated dependencies [ab0892358] +- Updated dependencies [37a7d26c4] +- Updated dependencies [8e083f41f] +- Updated dependencies [88da267cc] +- Updated dependencies [9c09a364f] +- Updated dependencies [01707438b] +- Updated dependencies [edb7d0775] +- Updated dependencies [818d45e94] +- Updated dependencies [0588be01f] +- Updated dependencies [b8abdda57] +- Updated dependencies [b7a124883] +- Updated dependencies [bc909178d] +- Updated dependencies [947d3c269] + - @backstage/plugin-cost-insights@0.5.5 + - @backstage/plugin-tech-radar@0.3.2 + - @backstage/cli@0.4.5 + - @backstage/plugin-scaffolder@0.3.6 + - @backstage/plugin-sentry@0.3.2 + - @backstage/plugin-catalog@0.2.10 + - @backstage/plugin-search@0.2.5 + - @backstage/plugin-catalog-import@0.3.3 + - @backstage/plugin-pagerduty@0.2.5 + ## 0.2.8 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 7541dd59a3..859372b1f8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,43 +1,43 @@ { "name": "example-app", - "version": "0.2.8", + "version": "0.2.14", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/cli": "^0.4.3", - "@backstage/core": "^0.4.2", - "@backstage/plugin-api-docs": "^0.4.1", - "@backstage/plugin-catalog": "^0.2.8", - "@backstage/plugin-catalog-import": "^0.3.2", - "@backstage/plugin-circleci": "^0.2.5", - "@backstage/plugin-cloudbuild": "^0.2.5", - "@backstage/plugin-cost-insights": "^0.5.2", - "@backstage/plugin-explore": "^0.2.2", - "@backstage/plugin-gcp-projects": "^0.2.2", - "@backstage/plugin-github-actions": "^0.2.6", - "@backstage/plugin-gitops-profiles": "^0.2.2", - "@backstage/plugin-graphiql": "^0.2.3", - "@backstage/plugin-org": "^0.3.2", - "@backstage/plugin-jenkins": "^0.3.4", - "@backstage/plugin-kubernetes": "^0.3.3", - "@backstage/plugin-lighthouse": "^0.2.6", - "@backstage/plugin-newrelic": "^0.2.2", - "@backstage/plugin-pagerduty": "0.2.4", - "@backstage/plugin-register-component": "^0.2.5", - "@backstage/plugin-rollbar": "^0.2.7", - "@backstage/plugin-scaffolder": "^0.3.5", - "@backstage/plugin-sentry": "^0.3.1", - "@backstage/plugin-search": "^0.2.4", - "@backstage/plugin-tech-radar": "^0.3.1", - "@backstage/plugin-techdocs": "^0.5.1", - "@backstage/plugin-user-settings": "^0.2.3", - "@backstage/plugin-welcome": "^0.2.3", - "@backstage/test-utils": "^0.1.6", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/cli": "^0.6.0", + "@backstage/core": "^0.6.0", + "@backstage/plugin-api-docs": "^0.4.4", + "@backstage/plugin-catalog": "^0.3.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/plugin-catalog-import": "^0.3.7", + "@backstage/plugin-circleci": "^0.2.7", + "@backstage/plugin-cloudbuild": "^0.2.8", + "@backstage/plugin-cost-insights": "^0.8.0", + "@backstage/plugin-explore": "^0.2.4", + "@backstage/plugin-gcp-projects": "^0.2.4", + "@backstage/plugin-github-actions": "^0.3.1", + "@backstage/plugin-gitops-profiles": "^0.2.4", + "@backstage/plugin-graphiql": "^0.2.7", + "@backstage/plugin-org": "^0.3.5", + "@backstage/plugin-jenkins": "^0.3.8", + "@backstage/plugin-kafka": "^0.2.1", + "@backstage/plugin-kubernetes": "^0.3.8", + "@backstage/plugin-lighthouse": "^0.2.9", + "@backstage/plugin-newrelic": "^0.2.4", + "@backstage/plugin-pagerduty": "0.2.7", + "@backstage/plugin-register-component": "^0.2.8", + "@backstage/plugin-rollbar": "^0.2.9", + "@backstage/plugin-scaffolder": "^0.4.2", + "@backstage/plugin-sentry": "^0.3.4", + "@backstage/plugin-search": "^0.2.7", + "@backstage/plugin-tech-radar": "^0.3.4", + "@backstage/plugin-techdocs": "^0.5.5", + "@backstage/plugin-user-settings": "^0.2.5", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", - "@octokit/rest": "^18.0.0", + "@octokit/rest": "^18.0.12", "@roadiehq/backstage-plugin-buildkite": "^0.1.3", "@roadiehq/backstage-plugin-github-insights": "^0.2.16", "@roadiehq/backstage-plugin-github-pull-requests": "^0.6.3", @@ -53,6 +53,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { + "@backstage/test-utils": "^0.1.6", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 627b5d19aa..00696736d1 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -15,29 +15,30 @@ */ import { - createApp, AlertDisplay, + createApp, + createRouteRef, + FlatRoutes, OAuthRequestDialog, SignInPage, - createRouteRef, } from '@backstage/core'; -import React from 'react'; -import Root from './components/Root'; -import * as plugins from './plugins'; -import { apis } from './apis'; -import { hot } from 'react-hot-loader/root'; -import { providers } from './identityProviders'; import { Router as CatalogRouter } from '@backstage/plugin-catalog'; -import { Router as DocsRouter } from '@backstage/plugin-techdocs'; +import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import'; +import { ExplorePage } from '@backstage/plugin-explore'; import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; -import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse'; import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component'; +import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; +import { Router as DocsRouter } from '@backstage/plugin-techdocs'; import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; -import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import'; -import { Route, Routes, Navigate } from 'react-router'; - +import React from 'react'; +import { hot } from 'react-hot-loader/root'; +import { Navigate, Route } from 'react-router'; +import { apis } from './apis'; import { EntityPage } from './components/catalog/EntityPage'; +import Root from './components/Root'; +import { providers } from './identityProviders'; +import * as plugins from './plugins'; const app = createApp({ apis, @@ -65,31 +66,32 @@ const catalogRouteRef = createRouteRef({ title: 'Service Catalog', }); -const AppRoutes = () => ( - +const routes = ( + } /> } /> - } /> + } /> + } /> } /> } /> - } /> + } /> } /> } /> {...deprecatedAppRoutes} - + ); const App = () => ( @@ -97,9 +99,7 @@ const App = () => ( - - - + {routes} ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 52dd397418..80706fe459 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -20,6 +20,7 @@ import HomeIcon from '@material-ui/icons/Home'; import ExtensionIcon from '@material-ui/icons/Extension'; import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; +import LayersIcon from '@material-ui/icons/Layers'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import MoneyIcon from '@material-ui/icons/MonetizationOn'; @@ -82,6 +83,7 @@ const Root = ({ children }: PropsWithChildren<{}>) => ( + {/* End global nav */} diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index a4d097b1d4..5474afa72c 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { ApiEntity, Entity, @@ -27,11 +28,8 @@ import { ProvidedApisCard, ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; -import { - AboutCard, - EntityPageLayout, - useEntity, -} from '@backstage/plugin-catalog'; +import { AboutCard, EntityPageLayout } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter, @@ -50,6 +48,7 @@ import { LatestRunCard as JenkinsLatestRunCard, Router as JenkinsRouter, } from '@backstage/plugin-jenkins'; +import { Router as KafkaRouter } from '@backstage/plugin-kafka'; import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; import { EmbeddedRouter as LighthouseRouter, @@ -57,11 +56,19 @@ import { LastLighthouseAuditCard, } from '@backstage/plugin-lighthouse'; import { - OwnershipCard, - MembersListCard, GroupProfileCard, + MembersListCard, + OwnershipCard, UserProfileCard, } from '@backstage/plugin-org'; +import { + isPluginApplicableToEntity as isPagerDutyAvailable, + PagerDutyCard, +} from '@backstage/plugin-pagerduty'; +import { + isRollbarAvailable, + Router as RollbarRouter, +} from '@backstage/plugin-rollbar'; import { Router as SentryRouter } from '@backstage/plugin-sentry'; import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; import { Button, Grid } from '@material-ui/core'; @@ -81,10 +88,6 @@ import { PullRequestsStatsCard, Router as PullRequestsRouter, } from '@roadiehq/backstage-plugin-github-pull-requests'; -import { - isPluginApplicableToEntity as isPagerDutyAvailable, - PagerDutyCard, -} from '@backstage/plugin-pagerduty'; import { isPluginApplicableToEntity as isTravisCIAvailable, RecentTravisCIBuildsWidget, @@ -155,6 +158,15 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => { ); }; +export const ErrorsSwitcher = ({ entity }: { entity: Entity }) => { + switch (true) { + case isRollbarAvailable(entity): + return ; + default: + return ; + } +}; + const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( @@ -214,9 +226,9 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( element={} /> } + path="/errors/*" + title="Errors" + element={} /> ( title="Code Insights" element={} /> + } + /> ); @@ -264,9 +281,9 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( element={} /> } + path="/errors/*" + title="Errors" + element={} /> ; + }; + /** * Content Security Policy options. * @@ -104,81 +109,4 @@ export interface Config { */ csp?: { [policyId: string]: string[] | false }; }; - - /** Configuration for integrations towards various external repository provider systems */ - integrations?: { - /** Integration configuration for Azure */ - azure?: Array<{ - /** - * The hostname of the given Azure instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - }>; - - /** Integration configuration for BitBucket */ - bitbucket?: Array<{ - /** - * The hostname of the given Bitbucket instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - /** - * The base url for the BitBucket API, for example https://api.bitbucket.org/2.0 - */ - apiBaseUrl?: string; - /** - * The username to use for authenticated requests. - * @visibility secret - */ - username?: string; - /** - * BitBucket app password used to authenticate requests. - * @visibility secret - */ - appPassword?: string; - }>; - - /** Integration configuration for GitHub */ - github?: Array<{ - /** - * The hostname of the given GitHub instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - /** - * The base url for the GitHub API, for example https://api.github.com - */ - apiBaseUrl?: string; - /** - * The base url for GitHub raw resources, for example https://raw.githubusercontent.com - */ - rawBaseUrl?: string; - }>; - - /** Integration configuration for GitLab */ - gitlab?: Array<{ - /** - * The hostname of the given GitLab instance - */ - host: string; - /** - * Token used to authenticate requests. - * @visibility secret - */ - token?: string; - }>; - }; } diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index a72fb2f52f..895d241c6a 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.4.1", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -31,8 +31,9 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", - "@backstage/config-loader": "^0.4.0", - "@backstage/integration": "^0.1.4", + "@backstage/config-loader": "^0.5.1", + "@backstage/integration": "^0.3.2", + "@octokit/rest": "^18.0.12", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "archiver": "^5.0.2", @@ -43,12 +44,13 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", "knex": "^0.21.6", "lodash": "^4.17.15", "logform": "^2.1.1", + "minimatch": "^3.0.4", "minimist": "^1.2.5", "morgan": "^1.10.0", "selfsigned": "^1.10.7", @@ -66,7 +68,7 @@ } }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.6.0", "@backstage/test-utils": "^0.1.5", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", @@ -82,7 +84,6 @@ "@types/tar": "^4.0.3", "@types/unzipper": "^0.10.3", "@types/webpack-env": "^1.15.2", - "@types/yaml": "^1.9.7", "get-port": "^5.1.1", "http-errors": "^1.7.3", "jest": "^26.0.1", diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 86beb65805..6989f3567b 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -37,7 +37,6 @@ export async function loadBackendConfig(options: Options): Promise { /* eslint-disable-next-line no-restricted-syntax */ const paths = findPaths(__dirname); const configs = await loadConfig({ - env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development', configRoot: paths.targetRoot, configPaths: configOpts.map(opt => resolvePath(opt)), }); diff --git a/packages/backend-common/src/errors.ts b/packages/backend-common/src/errors.ts index b68dc8e3f0..39703127e6 100644 --- a/packages/backend-common/src/errors.ts +++ b/packages/backend-common/src/errors.ts @@ -75,3 +75,8 @@ export class NotFoundError extends CustomErrorBase {} * resource. */ export class ConflictError extends CustomErrorBase {} + +/** + * The requested resource has not changed since last request. + */ +export class NotModifiedError extends CustomErrorBase {} diff --git a/packages/backend-common/src/logging/formats.ts b/packages/backend-common/src/logging/formats.ts index 4f7949f115..fcd193f521 100644 --- a/packages/backend-common/src/logging/formats.ts +++ b/packages/backend-common/src/logging/formats.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import * as winston from 'winston'; import { TransformableInfo } from 'logform'; diff --git a/packages/backend-common/src/logging/index.ts b/packages/backend-common/src/logging/index.ts index 0ebf0371e9..ef2e96f6c0 100644 --- a/packages/backend-common/src/logging/index.ts +++ b/packages/backend-common/src/logging/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export * from './formats'; export * from './rootLogger'; export * from './voidLogger'; diff --git a/packages/backend-common/src/logging/rootLogger.test.ts b/packages/backend-common/src/logging/rootLogger.test.ts index 07ef857d50..5506d195a9 100644 --- a/packages/backend-common/src/logging/rootLogger.test.ts +++ b/packages/backend-common/src/logging/rootLogger.test.ts @@ -15,7 +15,7 @@ */ import * as winston from 'winston'; -import { getRootLogger, setRootLogger } from './rootLogger'; +import { createRootLogger, getRootLogger, setRootLogger } from './rootLogger'; describe('rootLogger', () => { it('can replace the default logger', () => { @@ -29,4 +29,70 @@ describe('rootLogger', () => { expect.stringContaining('testing'), ); }); + + describe('createRootLoger', () => { + it('creates a new logger', () => { + const oldLogger = getRootLogger(); + const newLogger = createRootLogger(); + + expect(oldLogger).not.toBe(newLogger); + }); + + it('replaces the existing root logger', () => { + const oldLogger = getRootLogger(); + createRootLogger(); + const newLogger = getRootLogger(); + expect(oldLogger).not.toBe(newLogger); + }); + + it('can append additional default metadata', () => { + const format = winston.format.json(); + const logger = createRootLogger({ + format, + defaultMeta: { + appName: 'backstage', + appEnv: 'prod', + containerId: 'abc', + }, + }); + jest.spyOn(format, 'transform'); + + logger.info('testing'); + + expect(format.transform).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'testing', + service: 'backstage', + appName: 'backstage', + appEnv: 'prod', + containerId: 'abc', + }), + {}, + ); + }); + + it('can add override existing transports', () => { + const transport = new winston.transports.Console({ level: 'debug' }); + const logger = createRootLogger({ transports: [transport] }); + expect(logger.transports.length).toBe(1); + expect(logger.transports[0]).toBe(transport); + }); + + it('can append an additional transport', () => { + const logger = createRootLogger(); + const transport = new winston.transports.Console({ level: 'debug' }); + logger.add(transport); + expect(logger.transports.length).toBe(2); + expect(logger.transports[1]).toBe(transport); + expect(logger.transports[1].level).toBe('debug'); + }); + + it('can override default format', () => { + const format = winston.format(() => false)(); + const logger = createRootLogger({ format }); + expect( + logger.format.transform({ message: 'hello', level: 'info' }), + ).toBeFalsy(); + }); + }); }); diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index 306ed23444..a8cf4721d6 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -13,23 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { merge } from 'lodash'; import * as winston from 'winston'; +import { LoggerOptions } from 'winston'; import { coloredFormat } from './formats'; -let rootLogger: winston.Logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: - process.env.NODE_ENV === 'production' - ? winston.format.json() - : coloredFormat, - defaultMeta: { service: 'backstage' }, - transports: [ - new winston.transports.Console({ - silent: - process.env.JEST_WORKER_ID !== undefined && !process.env.LOG_LEVEL, - }), - ], -}); +let rootLogger: winston.Logger; export function getRootLogger(): winston.Logger { return rootLogger; @@ -38,3 +28,34 @@ export function getRootLogger(): winston.Logger { export function setRootLogger(newLogger: winston.Logger) { rootLogger = newLogger; } + +export function createRootLogger( + options: winston.LoggerOptions = {}, + env = process.env, +): winston.Logger { + const logger = winston.createLogger( + merge( + { + level: env.LOG_LEVEL || 'info', + format: winston.format.combine( + env.NODE_ENV === 'production' ? winston.format.json() : coloredFormat, + ), + defaultMeta: { + service: 'backstage', + }, + transports: [ + new winston.transports.Console({ + silent: env.JEST_WORKER_ID !== undefined && !env.LOG_LEVEL, + }), + ], + }, + options, + ), + ); + + setRootLogger(logger); + + return logger; +} + +rootLogger = createRootLogger(); diff --git a/packages/backend-common/src/middleware/errorHandler.test.ts b/packages/backend-common/src/middleware/errorHandler.test.ts index a7a3d64bd1..6d22c2f175 100644 --- a/packages/backend-common/src/middleware/errorHandler.test.ts +++ b/packages/backend-common/src/middleware/errorHandler.test.ts @@ -72,6 +72,9 @@ describe('errorHandler', () => { it('handles well-known error classes', async () => { const app = express(); + app.use('/NotModifiedError', () => { + throw new errors.NotModifiedError(); + }); app.use('/InputError', () => { throw new errors.InputError(); }); @@ -90,6 +93,7 @@ describe('errorHandler', () => { app.use(errorHandler()); const r = request(app); + expect((await r.get('/NotModifiedError')).status).toBe(304); expect((await r.get('/InputError')).status).toBe(400); expect((await r.get('/AuthenticationError')).status).toBe(401); expect((await r.get('/NotAllowedError')).status).toBe(403); diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts index 7365ce8b93..2f3b904d38 100644 --- a/packages/backend-common/src/middleware/errorHandler.ts +++ b/packages/backend-common/src/middleware/errorHandler.ts @@ -63,10 +63,10 @@ export function errorHandler( return ( error: Error, _request: Request, - response: Response, + res: Response, next: NextFunction, ) => { - if (response.headersSent) { + if (res.headersSent) { // If the headers have already been sent, do not send the response again // as this will throw an error in the backend. next(error); @@ -80,7 +80,9 @@ export function errorHandler( logger.error(error); } - response.status(status).send(message); + res.status(status); + res.setHeader('content-type', 'text/plain'); + res.send(message); }; } @@ -101,6 +103,8 @@ function getStatusCode(error: Error): number { // Handle well-known error types switch (error.name) { + case errors.NotModifiedError.name: + return 304; case errors.InputError.name: return 400; case errors.AuthenticationError.name: diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index 616cbaaadc..c937f8a40c 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -14,7 +14,9 @@ * limitations under the License. */ -import fs from 'fs'; +import * as os from 'os'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import path from 'path'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -23,6 +25,7 @@ import { getVoidLogger } from '../logging'; import { AzureUrlReader } from './AzureUrlReader'; import { msw } from '@backstage/test-utils'; import { ReadTreeResponseFactory } from './tree'; +import { NotModifiedError } from '../errors'; const logger = getVoidLogger(); @@ -30,6 +33,8 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + describe('AzureUrlReader', () => { const worker = setupServer(); msw.setupDefaultHandlers(worker); @@ -138,8 +143,23 @@ describe('AzureUrlReader', () => { }); describe('readTree', () => { + beforeEach(() => { + mockFs({ + [tmpDir]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + const repoBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'repo.zip'), + path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'), + ); + + const processor = new AzureUrlReader( + { host: 'dev.azure.com' }, + { treeResponseFactory }, ); beforeEach(() => { @@ -153,24 +173,85 @@ describe('AzureUrlReader', () => { ctx.body(repoBuffer), ), ), + rest.get( + // https://docs.microsoft.com/en-us/rest/api/azure/devops/git/commits/get%20commits?view=azure-devops-rest-6.0#on-a-branch + 'https://dev.azure.com/organization/project/_apis/git/repositories/repository/commits', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + count: 2, + value: [ + { + commitId: '123abc2', + comment: 'second commit', + }, + { + commitId: '123abc1', + comment: 'first commit', + }, + ], + }), + ), + ), ); }); it('returns the wanted files from an archive', async () => { - const processor = new AzureUrlReader( - { host: 'dev.azure.com' }, - { treeResponseFactory }, - ); - const response = await processor.readTree( 'https://dev.azure.com/organization/project/_git/repository', ); + expect(response.etag).toBe('123abc2'); + const files = await response.files(); expect(files.length).toBe(2); - const mkDocsFile = await files[1].content(); - const indexMarkdownFile = await files[0].content(); + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); + + expect(mkDocsFile.toString()).toBe('site_name: Test\n'); + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('creates a directory with the wanted files', async () => { + const response = await processor.readTree( + 'https://dev.azure.com/organization/project/_git/repository', + ); + + const dir = await response.dir({ targetDir: tmpDir }); + + await expect( + fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + + it('throws a NotModifiedError when given a etag in options', async () => { + const fnAzure = async () => { + await processor.readTree( + 'https://dev.azure.com/organization/project/_git/repository', + { etag: '123abc2' }, + ); + }; + + await expect(fnAzure).rejects.toThrow(NotModifiedError); + }); + + it('should not throw a NotModifiedError when given an outdated etag in options', async () => { + const response = await processor.readTree( + 'https://dev.azure.com/organization/project/_git/repository', + { etag: 'outdated123abc' }, + ); + + expect(response.etag).toBe('123abc2'); + const files = await response.files(); + + expect(files.length).toBe(2); + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); expect(mkDocsFile.toString()).toBe('site_name: Test\n'); expect(indexMarkdownFile.toString()).toBe('# Test\n'); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index ea716a1d4a..bc1b61a041 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -20,14 +20,16 @@ import { getAzureFileFetchUrl, getAzureDownloadUrl, getAzureRequestOptions, + getAzureCommitsUrl, } from '@backstage/integration'; import fetch from 'cross-fetch'; import { Readable } from 'stream'; -import { NotFoundError } from '../errors'; +import { NotFoundError, NotModifiedError } from '../errors'; import { ReaderFactory, ReadTreeOptions, ReadTreeResponse, + SearchResponse, UrlReader, } from './types'; import { ReadTreeResponseFactory } from './tree'; @@ -75,24 +77,50 @@ export class AzureUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const response = await fetch( - getAzureDownloadUrl(url), - getAzureRequestOptions(this.options, { Accept: 'application/zip' }), + // TODO: Support filepath based reading tree feature like other providers + + // Get latest commit SHA + + const commitsAzureResponse = await fetch( + getAzureCommitsUrl(url), + getAzureRequestOptions(this.options), ); - if (!response.ok) { - const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; - if (response.status === 404) { + if (!commitsAzureResponse.ok) { + const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`; + if (commitsAzureResponse.status === 404) { throw new NotFoundError(message); } throw new Error(message); } - return this.deps.treeResponseFactory.fromZipArchive({ - stream: (response.body as unknown) as Readable, + const commitSha = (await commitsAzureResponse.json()).value[0].commitId; + if (options?.etag && options.etag === commitSha) { + throw new NotModifiedError(); + } + + const archiveAzureResponse = await fetch( + getAzureDownloadUrl(url), + getAzureRequestOptions(this.options, { Accept: 'application/zip' }), + ); + if (!archiveAzureResponse.ok) { + const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`; + if (archiveAzureResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + return await this.deps.treeResponseFactory.fromZipArchive({ + stream: (archiveAzureResponse.body as unknown) as Readable, + etag: commitSha, filter: options?.filter, }); } + async search(): Promise { + throw new Error('AzureUrlReader does not implement search'); + } + 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 index e327770114..974c84b2c2 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -16,10 +16,12 @@ import { ConfigReader } from '@backstage/config'; import { msw } from '@backstage/test-utils'; -import fs from 'fs'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import path from 'path'; +import { NotModifiedError } from '../errors'; import { BitbucketUrlReader } from './BitbucketUrlReader'; import { ReadTreeResponseFactory } from './tree'; @@ -27,15 +29,24 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const bitbucketProcessor = new BitbucketUrlReader( + { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, + { treeResponseFactory }, +); + +const hostedBitbucketProcessor = new BitbucketUrlReader( + { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }, + { treeResponseFactory }, +); + describe('BitbucketUrlReader', () => { describe('implementation', () => { it('rejects unknown targets', async () => { - const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, - { treeResponseFactory }, - ); await expect( - processor.read('https://not.bitbucket.com/apa'), + bitbucketProcessor.read('https://not.bitbucket.com/apa'), ).rejects.toThrow( 'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket URL or file path', ); @@ -43,6 +54,16 @@ describe('BitbucketUrlReader', () => { }); describe('readTree', () => { + beforeEach(() => { + mockFs({ + '/tmp': mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + const worker = setupServer(); msw.setupDefaultHandlers(worker); @@ -55,14 +76,40 @@ describe('BitbucketUrlReader', () => { ), ); - it('returns the wanted files from an archive', async () => { + const privateBitbucketRepoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-server-repo.zip', + ), + ); + + beforeEach(() => { worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + mainbranch: { + type: 'branch', + name: 'master', + }, + }), + ), + ), rest.get( 'https://bitbucket.org/backstage/mock/get/master.zip', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-12ab34cd56ef.zip', + ), ctx.body(repoBuffer), ), ), @@ -76,17 +123,39 @@ describe('BitbucketUrlReader', () => { }), ), ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock.zip', + ), + ctx.body(privateBitbucketRepoBuffer), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), ); + }); - const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( + it('returns the wanted files from an archive', async () => { + const response = await bitbucketProcessor.readTree( 'https://bitbucket.org/backstage/mock/src/master', ); + expect(response.etag).toBe('12ab34cd56ef'); + const files = await response.files(); expect(files.length).toBe(2); @@ -97,39 +166,28 @@ describe('BitbucketUrlReader', () => { expect(mkDocsFile.toString()).toBe('site_name: Test\n'); }); + it('creates a directory with the wanted files', async () => { + const response = await bitbucketProcessor.readTree( + 'https://bitbucket.org/backstage/mock', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + it('uses private bitbucket host', async () => { - const privateBitbucketRepoBuffer = fs.readFileSync( - path.resolve( - 'src', - 'reading', - '__fixtures__', - 'bitbucket-server-repo.zip', - ), - ); - worker.use( - rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/zip'), - ctx.body(privateBitbucketRepoBuffer), - ), - ), - ); - - const processor = new BitbucketUrlReader( - { - host: 'bitbucket.mycompany.net', - apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', - }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( + const response = await hostedBitbucketProcessor.readTree( 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', ); + expect(response.etag).toBe('12ab34cd56ef'); + const files = await response.files(); expect(files.length).toBe(1); @@ -139,37 +197,12 @@ describe('BitbucketUrlReader', () => { }); it('returns the wanted files from an archive with a subpath', async () => { - worker.use( - rest.get( - 'https://bitbucket.org/backstage/mock/get/master.zip', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/zip'), - ctx.body(repoBuffer), - ), - ), - rest.get( - 'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master', - (_, res, ctx) => - res( - ctx.status(200), - ctx.json({ - values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], - }), - ), - ), - ); - - const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( + const response = await bitbucketProcessor.readTree( 'https://bitbucket.org/backstage/mock/src/master/docs', ); + expect(response.etag).toBe('12ab34cd56ef'); + const files = await response.files(); expect(files.length).toBe(1); @@ -177,5 +210,49 @@ describe('BitbucketUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + + it('creates a directory with the wanted files with a subpath', async () => { + const response = await bitbucketProcessor.readTree( + 'https://bitbucket.org/backstage/mock/src/master/docs', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + + it('throws a NotModifiedError when given a etag in options', async () => { + const fnBitbucket = async () => { + await bitbucketProcessor.readTree( + 'https://bitbucket.org/backstage/mock', + { etag: '12ab34cd56ef' }, + ); + }; + + await expect(fnBitbucket).rejects.toThrow(NotModifiedError); + }); + + it('should not throw a NotModifiedError when given an outdated etag in options', async () => { + const response = await bitbucketProcessor.readTree( + 'https://bitbucket.org/backstage/mock', + { etag: 'outdatedetag123abc' }, + ); + + expect(response.etag).toBe('12ab34cd56ef'); + }); + + it('should throw error when apiBaseUrl is missing', () => { + expect(() => { + /* eslint-disable no-new */ + new BitbucketUrlReader( + { + host: 'bitbucket.mycompany.net', + }, + { treeResponseFactory }, + ); + }).toThrowError('must configure an explicit apiBaseUrl'); + }); }); }); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 4367424423..e51c03e562 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -23,14 +23,15 @@ import { readBitbucketIntegrationConfigs, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import parseGitUri from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; import { Readable } from 'stream'; -import { NotFoundError } from '../errors'; +import { NotFoundError, NotModifiedError } from '../errors'; import { ReadTreeResponseFactory } from './tree'; import { ReaderFactory, ReadTreeOptions, ReadTreeResponse, + SearchResponse, UrlReader, } from './types'; @@ -101,37 +102,38 @@ export class BitbucketUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const gitUrl: parseGitUri.GitUrl = parseGitUri(url); - const { name: repoName, owner: project, resource, filepath } = gitUrl; + const { filepath } = parseGitUrl(url); - const isHosted = resource === 'bitbucket.org'; + const lastCommitShortHash = await this.getLastCommitShortHash(url); + if (options?.etag && options.etag === lastCommitShortHash) { + throw new NotModifiedError(); + } const downloadUrl = await getBitbucketDownloadUrl(url, this.config); - const response = await fetch( + const archiveBitbucketResponse = await fetch( downloadUrl, getBitbucketRequestOptions(this.config), ); - if (!response.ok) { - const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; - if (response.status === 404) { + if (!archiveBitbucketResponse.ok) { + const message = `Failed to read tree from ${url}, ${archiveBitbucketResponse.status} ${archiveBitbucketResponse.statusText}`; + if (archiveBitbucketResponse.status === 404) { throw new NotFoundError(message); } throw new Error(message); } - let folderPath = `${project}-${repoName}`; - if (isHosted) { - const lastCommitShortHash = await this.getLastCommitShortHash(url); - folderPath = `${project}-${repoName}-${lastCommitShortHash}`; - } - - return this.treeResponseFactory.fromZipArchive({ - stream: (response.body as unknown) as Readable, - path: `${folderPath}/${filepath}`, + return await this.treeResponseFactory.fromZipArchive({ + stream: (archiveBitbucketResponse.body as unknown) as Readable, + subpath: filepath, + etag: lastCommitShortHash, filter: options?.filter, }); } + async search(): Promise { + throw new Error('BitbucketUrlReader does not implement search'); + } + toString() { const { host, token, username, appPassword } = this.config; let authed = Boolean(token); @@ -141,14 +143,19 @@ export class BitbucketUrlReader implements UrlReader { return `bitbucket{host=${host},authed=${authed}}`; } - private async getLastCommitShortHash(url: string): Promise { - const { name: repoName, owner: project, ref } = parseGitUri(url); + private async getLastCommitShortHash(url: string): Promise { + const { resource, name: repoName, owner: project, ref } = parseGitUrl(url); let branch = ref; if (!branch) { branch = await getBitbucketDefaultBranch(url, this.config); } - const commitsApiUrl = `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`; + + const isHosted = resource === 'bitbucket.org'; + // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222 + const commitsApiUrl = isHosted + ? `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}` + : `${this.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`; const commitsResponse = await fetch( commitsApiUrl, @@ -163,14 +170,26 @@ export class BitbucketUrlReader implements UrlReader { } const commits = await commitsResponse.json(); - if ( - commits && - commits.values && - commits.values.length > 0 && - commits.values[0].hash - ) { - return commits.values[0].hash.substring(0, 12); + if (isHosted) { + if ( + commits && + commits.values && + commits.values.length > 0 && + commits.values[0].hash + ) { + return commits.values[0].hash.substring(0, 12); + } + } else { + if ( + commits && + commits.values && + commits.values.length > 0 && + commits.values[0].id + ) { + return commits.values[0].id.substring(0, 12); + } } + throw new Error(`Failed to read response from ${commitsApiUrl}`); } } diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts new file mode 100644 index 0000000000..8dc4aba29b --- /dev/null +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -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 { ConfigReader } from '@backstage/config'; +import { msw } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { getVoidLogger } from '../logging'; +import { FetchUrlReader } from './FetchUrlReader'; +import { ReadTreeResponseFactory } from './tree'; + +describe('FetchUrlReader', () => { + const worker = setupServer(); + + msw.setupDefaultHandlers(worker); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('factory should create a single entry with a predicate that matches config', async () => { + const entries = FetchUrlReader.factory({ + config: new ConfigReader({ + backend: { + reading: { + allow: [ + { host: 'example.com' }, + { host: 'example.com:700' }, + { host: '*.examples.org' }, + { host: '*.examples.org:700' }, + ], + }, + }, + }), + logger: getVoidLogger(), + treeResponseFactory: ReadTreeResponseFactory.create({ + config: new ConfigReader({}), + }), + }); + + expect(entries.length).toBe(1); + const [{ predicate }] = entries; + + expect(predicate(new URL('https://example.com/test'))).toBe(true); + expect(predicate(new URL('https://a.example.com/test'))).toBe(false); + expect(predicate(new URL('https://example.com:600/test'))).toBe(false); + expect(predicate(new URL('https://a.example.com:600/test'))).toBe(false); + expect(predicate(new URL('https://example.com:700/test'))).toBe(true); + expect(predicate(new URL('https://a.example.com:700/test'))).toBe(false); + expect(predicate(new URL('https://other.com/test'))).toBe(false); + expect(predicate(new URL('https://examples.org/test'))).toBe(false); + expect(predicate(new URL('https://a.examples.org/test'))).toBe(true); + expect(predicate(new URL('https://a.b.examples.org/test'))).toBe(true); + expect(predicate(new URL('https://examples.org:600/test'))).toBe(false); + expect(predicate(new URL('https://a.examples.org:600/test'))).toBe(false); + expect(predicate(new URL('https://a.b.examples.org:600/test'))).toBe(false); + expect(predicate(new URL('https://examples.org:700/test'))).toBe(false); + expect(predicate(new URL('https://a.examples.org:700/test'))).toBe(true); + expect(predicate(new URL('https://a.b.examples.org:700/test'))).toBe(true); + }); +}); diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index 1d1784590c..0b26dfe911 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -16,12 +16,44 @@ import fetch from 'cross-fetch'; import { NotFoundError } from '../errors'; -import { ReadTreeResponse, UrlReader } from './types'; +import { + ReaderFactory, + ReadTreeResponse, + SearchResponse, + UrlReader, +} from './types'; /** * A UrlReader that does a plain fetch of the URL. */ export class FetchUrlReader implements UrlReader { + /** + * The factory creates a single reader that will be used for reading any URL that's listed + * in configuration at `backend.reading.allow`. The allow list contains a list of objects describing + * targets to allow, containing the following fields: + * + * `host`: + * Either full hostnames to match, or subdomain wildcard matchers with a leading `*`. + * For example `example.com` and `*.example.com` are valid values, `prod.*.example.com` is not. + */ + static factory: ReaderFactory = ({ config }) => { + const predicates = + config + .getOptionalConfigArray('backend.reading.allow') + ?.map(allowConfig => { + const host = allowConfig.getString('host'); + if (host.startsWith('*.')) { + const suffix = host.slice(1); + return (url: URL) => url.host.endsWith(suffix); + } + return (url: URL) => url.host === host; + }) ?? []; + + const reader = new FetchUrlReader(); + const predicate = (url: URL) => predicates.some(p => p(url)); + return [{ reader, predicate }]; + }; + async read(url: string): Promise { let response: Response; try { @@ -41,10 +73,14 @@ export class FetchUrlReader implements UrlReader { throw new Error(message); } - readTree(): Promise { + async readTree(): Promise { throw new Error('FetchUrlReader does not implement readTree'); } + async search(): Promise { + throw new Error('FetchUrlReader does not implement search'); + } + toString() { return 'fetch{}'; } diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index f842adcf90..d4e579b143 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -15,71 +15,234 @@ */ import { ConfigReader } from '@backstage/config'; +import { GithubCredentialsProvider } from '@backstage/integration'; import { msw } from '@backstage/test-utils'; -import fs from 'fs'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import path from 'path'; -import { GithubUrlReader } from './GithubUrlReader'; +import { NotFoundError, NotModifiedError } from '../errors'; +import { + GhBlobResponse, + GhBranchResponse, + GhRepoResponse, + GhTreeResponse, + GithubUrlReader, +} from './GithubUrlReader'; import { ReadTreeResponseFactory } from './tree'; const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const mockCredentialsProvider = ({ + getCredentials: jest.fn().mockResolvedValue({ headers: {} }), +} as unknown) as GithubCredentialsProvider; + +const githubProcessor = new GithubUrlReader( + { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }, + { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, +); + +const gheProcessor = new GithubUrlReader( + { + host: 'ghe.github.com', + apiBaseUrl: 'https://ghe.github.com/api/v3', + }, + { treeResponseFactory, credentialsProvider: mockCredentialsProvider }, +); + describe('GithubUrlReader', () => { + const worker = setupServer(); + + msw.setupDefaultHandlers(worker); + + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('implementation', () => { it('rejects unknown targets', async () => { - const processor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, - ); await expect( - processor.read('https://not.github.com/apa'), + githubProcessor.read('https://not.github.com/apa'), ).rejects.toThrow( 'Incorrect URL: https://not.github.com/apa, Error: Invalid GitHub URL or file path', ); }); }); - describe('readTree', () => { - const worker = setupServer(); + /* + * read + */ - msw.setupDefaultHandlers(worker); + describe('read', () => { + it('should use the headers from the credentials provider to the fetch request when doing read', async () => { + expect.assertions(2); + + const mockHeaders = { + Authorization: 'bearer blah', + otherheader: 'something', + }; + + (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + headers: mockHeaders, + }); + + worker.use( + rest.get( + 'https://api.github.com/repos/backstage/mock/tree/contents/?ref=main', + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockHeaders.Authorization, + ); + expect(req.headers.get('otherheader')).toBe( + mockHeaders.otherheader, + ); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.body('foo'), + ); + }, + ), + ); + + await githubProcessor.read( + 'https://github.com/backstage/mock/tree/blob/main', + ); + }); + }); + + /* + * readTree + */ + + describe('readTree', () => { + beforeEach(() => { + mockFs({ + '/tmp': mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); const repoBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'repo.tar.gz'), + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'backstage-mock-etag123.tar.gz', + ), ); + const reposGithubApiResponse = { + id: 123, + full_name: 'backstage/mock', + default_branch: 'main', + branches_url: + 'https://api.github.com/repos/backstage/mock/branches{/branch}', + archive_url: + 'https://api.github.com/repos/backstage/mock/{archive_format}{/ref}', + } as Partial; + + const reposGheApiResponse = { + id: 123, + full_name: 'backstage/mock', + default_branch: 'main', + branches_url: + 'https://ghe.github.com/api/v3/repos/backstage/mock/branches{/branch}', + archive_url: + 'https://ghe.github.com/api/v3/repos/backstage/mock/{archive_format}{/ref}', + } as Partial; + + const branchesApiResponse = { + name: 'main', + commit: { + sha: 'etag123abc', + }, + } as Partial; + beforeEach(() => { worker.use( + rest.get('https://api.github.com/repos/backstage/mock', (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(reposGithubApiResponse), + ), + ), rest.get( - 'https://github.com/backstage/mock/archive/repo.tar.gz', + 'https://api.github.com/repos/backstage/mock/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchesApiResponse), + ), + ), + rest.get( + 'https://api.github.com/repos/backstage/mock/tarball/etag123abc', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/x-gzip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-etag123.tar.gz', + ), ctx.body(repoBuffer), ), ), + rest.get( + 'https://api.github.com/repos/backstage/mock/branches/branchDoesNotExist', + (_, res, ctx) => res(ctx.status(404)), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/tarball/etag123abc', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-etag123.tar.gz', + ), + ctx.body(repoBuffer), + ), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(reposGheApiResponse), + ), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchesApiResponse), + ), + ), ); }); it('returns the wanted files from an archive', async () => { - const processor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock/tree/main', ); - const response = await processor.readTree( - 'https://github.com/backstage/mock/tree/repo', - ); + expect(response.etag).toBe('etag123abc'); const files = await response.files(); @@ -91,75 +254,513 @@ describe('GithubUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); - it('includes the subdomain in the github url', async () => { - worker.resetHandlers(); + it('creates a directory with the wanted files', async () => { + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + + it('should use the headers from the credentials provider to the fetch request', async () => { + expect.assertions(2); + + const mockHeaders = { + Authorization: 'bearer blah', + otherheader: 'something', + }; + + (mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({ + headers: mockHeaders, + }); + worker.use( rest.get( - 'https://ghe.github.com/backstage/mock/archive/repo.tar.gz', + 'https://ghe.github.com/api/v3/repos/backstage/mock/tarball/etag123abc', + (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe( + mockHeaders.Authorization, + ); + expect(req.headers.get('otherheader')).toBe( + mockHeaders.otherheader, + ); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-etag123.tar.gz', + ), + ctx.body(repoBuffer), + ); + }, + ), + ); + + await gheProcessor.readTree( + 'https://ghe.github.com/backstage/mock/tree/main', + ); + }); + + it('includes the subdomain in the github url', async () => { + const response = await gheProcessor.readTree( + 'https://ghe.github.com/backstage/mock/tree/main/docs', + ); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('returns the wanted files from an archive with a subpath', async () => { + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock/tree/main/docs', + ); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('creates a directory with the wanted files with subpath', async () => { + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock/tree/main/docs', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + + it('throws a NotModifiedError when given a etag in options', async () => { + const fnGithub = async () => { + await githubProcessor.readTree('https://github.com/backstage/mock', { + etag: 'etag123abc', + }); + }; + + const fnGhe = async () => { + await gheProcessor.readTree( + 'https://ghe.github.com/backstage/mock/tree/main/docs', + { + etag: 'etag123abc', + }, + ); + }; + + await expect(fnGithub).rejects.toThrow(NotModifiedError); + await expect(fnGhe).rejects.toThrow(NotModifiedError); + }); + + it('should not throw error when given an outdated etag in options', async () => { + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock/tree/main', + { + etag: 'outdatedetag123abc', + }, + ); + expect((await response.files()).length).toBe(2); + }); + + it('should detect the default branch', async () => { + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock', + ); + expect((await response.files()).length).toBe(2); + }); + + it('should throw error on missing branch', async () => { + const fnGithub = async () => { + await githubProcessor.readTree( + 'https://github.com/backstage/mock/tree/branchDoesNotExist', + ); + }; + await expect(fnGithub).rejects.toThrow(NotFoundError); + }); + + it('should throw error when apiBaseUrl is missing', () => { + expect(() => { + /* eslint-disable no-new */ + new GithubUrlReader( + { + host: 'ghe.mycompany.net', + }, + { + treeResponseFactory, + credentialsProvider: mockCredentialsProvider, + }, + ); + }).toThrowError('must configure an explicit apiBaseUrl'); + }); + }); + + /* + * search + */ + + describe('search', () => { + beforeEach(() => { + mockFs({ '/tmp': mockFs.directory() }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + const repoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'backstage-mock-etag123.tar.gz', + ), + ); + + const githubTreeContents: GhTreeResponse['tree'] = [ + { + path: 'mkdocs.yml', + type: 'blob', + url: 'https://api.github.com/repos/backstage/mock/git/blobs/1', + }, + { + path: 'docs', + type: 'tree', + url: 'https://api.github.com/repos/backstage/mock/git/trees/2', + }, + { + path: 'docs/index.md', + type: 'blob', + url: 'https://api.github.com/repos/backstage/mock/git/blobs/3', + }, + ]; + + const gheTreeContents: GhTreeResponse['tree'] = [ + { + path: 'mkdocs.yml', + type: 'blob', + url: 'https://ghe.github.com/api/v3/repos/backstage/mock/git/blobs/1', + }, + { + path: 'docs', + type: 'tree', + url: 'https://ghe.github.com/api/v3/repos/backstage/mock/git/trees/2', + }, + { + path: 'docs/index.md', + type: 'blob', + url: 'https://ghe.github.com/api/v3/repos/backstage/mock/git/blobs/3', + }, + ]; + + // Tarballs + beforeEach(() => { + worker.use( + rest.get( + 'https://api.github.com/repos/backstage/mock/tarball/etag123abc', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/x-gzip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-etag123.tar.gz', + ), + ctx.body(repoBuffer), + ), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/tarball/etag123abc', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/x-gzip'), + ctx.set( + 'content-disposition', + 'attachment; filename=backstage-mock-etag123.tar.gz', + ), ctx.body(repoBuffer), ), ), ); - - const processor = new GithubUrlReader( - { - host: 'ghe.github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( - 'https://ghe.github.com/backstage/mock/tree/repo/docs', - ); - - const files = await response.files(); - - expect(files.length).toBe(1); - const indexMarkdownFile = await files[0].content(); - - expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); - it('must specify a branch', async () => { - const processor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', + // Repo details + beforeEach(() => { + const githubResponse = { + id: 123, + full_name: 'backstage/mock', + default_branch: 'main', + branches_url: + 'https://api.github.com/repos/backstage/mock/branches{/branch}', + archive_url: + 'https://api.github.com/repos/backstage/mock/{archive_format}{/ref}', + trees_url: + 'https://api.github.com/repos/backstage/mock/git/trees{/sha}', + } as Partial; + + const gheResponse = { + id: 123, + full_name: 'backstage/mock', + default_branch: 'main', + branches_url: + 'https://ghe.github.com/api/v3/repos/backstage/mock/branches{/branch}', + archive_url: + 'https://ghe.github.com/api/v3/repos/backstage/mock/{archive_format}{/ref}', + trees_url: + 'https://ghe.github.com/api/v3/repos/backstage/mock/git/trees{/sha}', + } as Partial; + + worker.use( + rest.get('https://api.github.com/repos/backstage/mock', (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(githubResponse), + ), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(gheResponse), + ), + ), + ); + }); + + // Branch details + beforeEach(() => { + const response = { + name: 'main', + commit: { + sha: 'etag123abc', }, - { treeResponseFactory }, + } as Partial; + + worker.use( + rest.get( + 'https://api.github.com/repos/backstage/mock/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(response), + ), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(response), + ), + ), + rest.get( + 'https://api.github.com/repos/backstage/mock/branches/branchDoesNotExist', + (_, res, ctx) => res(ctx.status(404)), + ), + ); + }); + + // Blobs + beforeEach(() => { + const blob1Response = { + content: Buffer.from('site_name: Test\n').toString('base64'), + } as Partial; + + const blob3Response = { + content: Buffer.from('# Test\n').toString('base64'), + } as Partial; + + worker.use( + rest.get( + 'https://api.github.com/repos/backstage/mock/git/blobs/1', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(blob1Response), + ), + ), + rest.get( + 'https://api.github.com/repos/backstage/mock/git/blobs/3', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(blob3Response), + ), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/git/blobs/1', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(blob1Response), + ), + ), + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/git/blobs/3', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(blob3Response), + ), + ), + ); + }); + + async function runTests(reader: GithubUrlReader, baseUrl: string) { + const r1 = await reader.search( + `${baseUrl}/backstage/mock/tree/main/**/*`, + ); + expect(r1.etag).toBe('etag123abc'); + expect(r1.files.length).toBe(2); + + const r2 = await reader.search( + `${baseUrl}/backstage/mock/tree/main/**/*`, + { etag: 'somethingElse' }, + ); + expect(r2.etag).toBe('etag123abc'); + expect(r2.files.length).toBe(2); + + const r3 = await reader.search(`${baseUrl}/backstage/mock/tree/main/o`); + expect(r3.files.length).toBe(0); + + const r4 = await reader.search( + `${baseUrl}/backstage/mock/tree/main/*docs*`, + ); + expect(r4.files.length).toBe(1); + expect(r4.files[0].url).toBe( + `${baseUrl}/backstage/mock/tree/main/mkdocs.yml`, + ); + await expect(r4.files[0].content()).resolves.toEqual( + Buffer.from('site_name: Test\n'), ); + const r5 = await reader.search( + `${baseUrl}/backstage/mock/tree/main/*/index.*`, + ); + expect(r5.files.length).toBe(1); + expect(r5.files[0].url).toBe( + `${baseUrl}/backstage/mock/tree/main/docs/index.md`, + ); + await expect(r5.files[0].content()).resolves.toEqual( + Buffer.from('# Test\n'), + ); + } + + // eslint-disable-next-line jest/expect-expect + it('succeeds on github when going via repo listing', async () => { + worker.use( + rest.get( + 'https://api.github.com/repos/backstage/mock/git/trees/etag123abc', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + truncated: false, + tree: githubTreeContents, + } as Partial), + ), + ), + ); + await runTests(githubProcessor, 'https://github.com'); + }); + + // eslint-disable-next-line jest/expect-expect + it('succeeds on github when going via readTree', async () => { + worker.use( + rest.get( + 'https://api.github.com/repos/backstage/mock/git/trees/etag123abc', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + truncated: true, + tree: [], + } as Partial), + ), + ), + ); + await runTests(githubProcessor, 'https://github.com'); + }); + + // eslint-disable-next-line jest/expect-expect + it('succeeds on ghe when going via repo listing', async () => { + worker.use( + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/git/trees/etag123abc', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + truncated: false, + tree: gheTreeContents, + } as Partial), + ), + ), + ); + await runTests(gheProcessor, 'https://ghe.github.com'); + }); + + // eslint-disable-next-line jest/expect-expect + it('succeeds on ghe when going via readTree', async () => { + worker.use( + rest.get( + 'https://ghe.github.com/api/v3/repos/backstage/mock/git/trees/etag123abc', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + truncated: true, + tree: [], + } as Partial), + ), + ), + ); + await runTests(gheProcessor, 'https://ghe.github.com'); + }); + + it('throws NotModifiedError when same etag', async () => { await expect( - processor.readTree('https://github.com/backstage/mock'), - ).rejects.toThrow( - 'GitHub URL must contain branch to be able to fetch tree', - ); + githubProcessor.search( + 'https://githib.com/backstage/mock/tree/main/**/*', + { etag: 'etag123abc' }, + ), + ).rejects.toThrow(NotModifiedError); }); - it('returns the wanted files from an archive with a subpath', async () => { - const processor = new GithubUrlReader( - { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( - 'https://github.com/backstage/mock/tree/repo/docs', - ); - - const files = await response.files(); - - expect(files.length).toBe(1); - const indexMarkdownFile = await files[0].content(); - - expect(indexMarkdownFile.toString()).toBe('# Test\n'); + it('throws NotFoundError when missing branch', async () => { + await expect( + githubProcessor.search( + 'https://githib.com/backstage/mock/tree/branchDoesNotExist/**/*', + ), + ).rejects.toThrow(NotFoundError); }); }); }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 5ca2a99692..291740a9fc 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -15,23 +15,33 @@ */ import { + getGitHubFileFetchUrl, + GithubCredentialsProvider, GitHubIntegrationConfig, readGitHubIntegrationConfigs, - getGitHubFileFetchUrl, - getGitHubRequestOptions, } from '@backstage/integration'; +import { RestEndpointMethodTypes } from '@octokit/rest'; import fetch from 'cross-fetch'; -import parseGitUri from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; +import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; -import { InputError, NotFoundError } from '../errors'; +import { NotFoundError, NotModifiedError } from '../errors'; import { ReadTreeResponseFactory } from './tree'; import { ReaderFactory, ReadTreeOptions, ReadTreeResponse, + SearchOptions, + SearchResponse, + SearchResponseFile, UrlReader, } from './types'; +export type GhRepoResponse = RestEndpointMethodTypes['repos']['get']['response']['data']; +export type GhBranchResponse = RestEndpointMethodTypes['repos']['getBranch']['response']['data']; +export type GhTreeResponse = RestEndpointMethodTypes['git']['getTree']['response']['data']; +export type GhBlobResponse = RestEndpointMethodTypes['git']['getBlob']['response']['data']; + /** * A processor that adds the ability to read files from GitHub v3 APIs, such as * the one exposed by GitHub itself. @@ -42,7 +52,11 @@ export class GithubUrlReader implements UrlReader { config.getOptionalConfigArray('integrations.github') ?? [], ); return configs.map(provider => { - const reader = new GithubUrlReader(provider, { treeResponseFactory }); + const credentialsProvider = GithubCredentialsProvider.create(provider); + const reader = new GithubUrlReader(provider, { + treeResponseFactory, + credentialsProvider, + }); const predicate = (url: URL) => url.host === provider.host; return { reader, predicate }; }); @@ -50,7 +64,10 @@ export class GithubUrlReader implements UrlReader { constructor( private readonly config: GitHubIntegrationConfig, - private readonly deps: { treeResponseFactory: ReadTreeResponseFactory }, + private readonly deps: { + treeResponseFactory: ReadTreeResponseFactory; + credentialsProvider: GithubCredentialsProvider; + }, ) { if (!config.apiBaseUrl && !config.rawBaseUrl) { throw new Error( @@ -61,11 +78,17 @@ export class GithubUrlReader implements UrlReader { async read(url: string): Promise { const ghUrl = getGitHubFileFetchUrl(url, this.config); - const options = getGitHubRequestOptions(this.config); - + const { headers } = await this.deps.credentialsProvider.getCredentials({ + url, + }); let response: Response; try { - response = await fetch(ghUrl.toString(), options); + response = await fetch(ghUrl.toString(), { + headers: { + ...headers, + Accept: 'application/vnd.github.v3.raw', + }, + }); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -85,50 +108,190 @@ export class GithubUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const { - name: repoName, - ref, - protocol, - resource, - full_name, - filepath, - } = parseGitUri(url); + const repoDetails = await this.getRepoDetails(url); + const commitSha = repoDetails.branch.commit.sha!; - if (!ref) { - // TODO(Rugvip): We should add support for defaulting to the default branch - throw new InputError( - 'GitHub URL must contain branch to be able to fetch tree', - ); + if (options?.etag && options.etag === commitSha) { + throw new NotModifiedError(); } - // TODO(Rugvip): use API to fetch URL instead - const response = await fetch( - new URL( - `${protocol}://${resource}/${full_name}/archive/${ref}.tar.gz`, - ).toString(), - getGitHubRequestOptions(this.config), - ); - if (!response.ok) { - const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; - if (response.status === 404) { - throw new NotFoundError(message); - } - throw new Error(message); - } - - const path = `${repoName}-${ref}/${filepath}`; - - return this.deps.treeResponseFactory.fromTarArchive({ - // TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want - // to stick to using that in exclusively backend code. - stream: (response.body as unknown) as Readable, - path, - filter: options?.filter, + const { filepath } = parseGitUrl(url); + const { headers } = await this.deps.credentialsProvider.getCredentials({ + url, }); + + return this.doReadTree( + repoDetails.repo.archive_url, + commitSha, + filepath, + { headers }, + options, + ); + } + + async search(url: string, options?: SearchOptions): Promise { + const repoDetails = await this.getRepoDetails(url); + const commitSha = repoDetails.branch.commit.sha!; + + if (options?.etag && options.etag === commitSha) { + throw new NotModifiedError(); + } + + const { filepath } = parseGitUrl(url); + const { headers } = await this.deps.credentialsProvider.getCredentials({ + url, + }); + + const files = await this.doSearch( + url, + repoDetails.repo.trees_url, + repoDetails.repo.archive_url, + commitSha, + filepath, + { headers }, + ); + + return { files, etag: commitSha }; } toString() { const { host, token } = this.config; return `github{host=${host},authed=${Boolean(token)}}`; } + + private async doReadTree( + archiveUrl: string, + sha: string, + subpath: string, + init: RequestInit, + options?: ReadTreeOptions, + ): Promise { + // archive_url looks like "https://api.github.com/repos/owner/repo/{archive_format}{/ref}" + const archive = await this.fetchResponse( + archiveUrl + .replace('{archive_format}', 'tarball') + .replace('{/ref}', `/${sha}`), + init, + ); + + return await this.deps.treeResponseFactory.fromTarArchive({ + // TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want + // to stick to using that in exclusively backend code. + stream: (archive.body as unknown) as Readable, + subpath, + etag: sha, + filter: options?.filter, + }); + } + + private async doSearch( + url: string, + treesUrl: string, + archiveUrl: string, + sha: string, + query: string, + init: RequestInit, + ): Promise { + function pathToUrl(path: string): string { + // TODO(freben): Use the integration package facility for this instead + // pathname starts as /backstage/backstage/blob/master/ + const updated = new URL(url); + const base = updated.pathname.split('/').slice(1, 5).join('/'); + updated.pathname = `${base}/${path}`; + return updated.toString(); + } + + const matcher = new Minimatch(query.replace(/^\/+/, '')); + + // trees_url looks like "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}" + const recursiveTree: GhTreeResponse = await this.fetchJson( + treesUrl.replace('{/sha}', `/${sha}?recursive=true`), + init, + ); + + // The simple case is that we got the entire tree in a single operation. + if (!recursiveTree.truncated) { + const matching = recursiveTree.tree.filter( + item => + item.type === 'blob' && + item.path && + item.url && + matcher.match(item.path), + ); + + return matching.map(item => ({ + url: pathToUrl(item.path!), + content: async () => { + const blob: GhBlobResponse = await this.fetchJson(item.url!, init); + return Buffer.from(blob.content, 'base64'); + }, + })); + } + + // For larger repos, we leverage readTree and filter through that instead + const tree = await this.doReadTree(archiveUrl, sha, '', init, { + filter: path => matcher.match(path), + }); + const files = await tree.files(); + + return files.map(file => ({ + url: pathToUrl(file.path), + content: file.content, + })); + } + + private async getRepoDetails( + url: string, + ): Promise<{ + repo: GhRepoResponse; + branch: GhBranchResponse; + }> { + const parsed = parseGitUrl(url); + const { ref, full_name } = parsed; + + // Caveat: The ref will totally be incorrect if the branch name includes a + // slash. Thus, some operations can not work on URLs containing branch + // names that have a slash in them. + + const { headers } = await this.deps.credentialsProvider.getCredentials({ + url, + }); + + const repo: GhRepoResponse = await this.fetchJson( + `${this.config.apiBaseUrl}/repos/${full_name}`, + { headers }, + ); + + // branches_url looks like "https://api.github.com/repos/owner/repo/branches{/branch}" + const branch: GhBranchResponse = await this.fetchJson( + repo.branches_url.replace('{/branch}', `/${ref || repo.default_branch}`), + { headers }, + ); + + return { repo, branch }; + } + + private async fetchResponse( + url: string | URL, + init: RequestInit, + ): Promise { + const urlAsString = url.toString(); + + const response = await fetch(urlAsString, init); + + if (!response.ok) { + const message = `Request failed for ${urlAsString}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + return response; + } + + private async fetchJson(url: string | URL, init: RequestInit): Promise { + const response = await this.fetchResponse(url, init); + return await response.json(); + } } diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 6cc5e30dbd..b18e7a4294 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -16,13 +16,15 @@ import { ConfigReader } from '@backstage/config'; import { msw } from '@backstage/test-utils'; -import fs from 'fs'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import path from 'path'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; import { ReadTreeResponseFactory } from './tree'; +import { NotModifiedError, NotFoundError } from '../errors'; const logger = getVoidLogger(); @@ -30,6 +32,22 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const gitlabProcessor = new GitlabUrlReader( + { + host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + { treeResponseFactory }, +); + +const hostedGitlabProcessor = new GitlabUrlReader( + { + host: 'gitlab.mycompany.com', + apiBaseUrl: 'https://gitlab.mycompany.com/api/v4', + }, + { treeResponseFactory }, +); + describe('GitlabUrlReader', () => { const worker = setupServer(); msw.setupDefaultHandlers(worker); @@ -136,64 +154,151 @@ describe('GitlabUrlReader', () => { }); describe('readTree', () => { - const repoBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'repo.zip'), + beforeEach(() => { + mockFs({ + '/tmp': mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + const archiveBuffer = fs.readFileSync( + path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), ); + const projectGitlabApiResponse = { + id: 11111111, + default_branch: 'main', + }; + + const branchGitlabApiResponse = { + commit: { + id: 'sha123abc', + }, + }; + beforeEach(() => { worker.use( rest.get( - 'https://gitlab.com/backstage/mock/-/archive/repo/mock-repo.zip', + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), - ctx.body(repoBuffer), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(projectGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/branchDoesNotExist', + (_, res, ctx) => res(ctx.status(404)), + ), + rest.get( + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(projectGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/branches/main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(branchGitlabApiResponse), + ), + ), + rest.get( + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), ), ), ); }); it('returns the wanted files from an archive', async () => { - const processor = new GitlabUrlReader( - { host: 'gitlab.com' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( - 'https://gitlab.com/backstage/mock/tree/repo', + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main', ); const files = await response.files(); expect(files.length).toBe(2); - const indexMarkdownFile = await files[0].content(); - const mkDocsFile = await files[1].content(); + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); expect(mkDocsFile.toString()).toBe('site_name: Test\n'); expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + it('creates a directory with the wanted files', async () => { + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + it('returns the wanted files from hosted gitlab', async () => { worker.use( rest.get( - 'https://git.mycompany.com/backstage/mock/-/archive/repo/mock-repo.zip', + 'https://gitlab.mycompany.com/backstage/mock/-/archive/main.zip', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), - ctx.body(repoBuffer), + ctx.set( + 'content-disposition', + 'attachment; filename="mock-main-sha123abc.zip"', + ), + ctx.body(archiveBuffer), ), ), ); - const processor = new GitlabUrlReader( - { host: 'git.mycompany.com' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( - 'https://git.mycompany.com/backstage/mock/tree/repo/docs', + const response = await hostedGitlabProcessor.readTree( + 'https://gitlab.mycompany.com/backstage/mock/tree/main/docs', ); const files = await response.files(); @@ -204,27 +309,9 @@ describe('GitlabUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); - it('throws an error when branch is not specified', async () => { - const processor = new GitlabUrlReader( - { host: 'gitlab.com' }, - { treeResponseFactory }, - ); - - await expect( - processor.readTree('https://gitlab.com/backstage/mock'), - ).rejects.toThrow( - 'GitLab URL must contain a branch to be able to fetch its tree', - ); - }); - it('returns the wanted files from an archive with a subpath', async () => { - const processor = new GitlabUrlReader( - { host: 'gitlab.com' }, - { treeResponseFactory }, - ); - - const response = await processor.readTree( - 'https://gitlab.com/backstage/mock/tree/repo/docs', + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main/docs', ); const files = await response.files(); @@ -234,5 +321,75 @@ describe('GitlabUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + + it('creates a directory with the wanted files with subpath', async () => { + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main/docs', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + + it('throws a NotModifiedError when given a etag in options', async () => { + const fnGitlab = async () => { + await gitlabProcessor.readTree('https://gitlab.com/backstage/mock', { + etag: 'sha123abc', + }); + }; + + const fnHostedGitlab = async () => { + await hostedGitlabProcessor.readTree( + 'https://gitlab.mycompany.com/backstage/mock', + { + etag: 'sha123abc', + }, + ); + }; + + await expect(fnGitlab).rejects.toThrow(NotModifiedError); + await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError); + }); + + it('should not throw error when given an outdated etag in options', async () => { + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main', + { + etag: 'outdatedsha123abc', + }, + ); + expect((await response.files()).length).toBe(2); + }); + + it('should detect the default branch', async () => { + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock', + ); + expect((await response.files()).length).toBe(2); + }); + + it('should throw error on missing branch', async () => { + const fnGithub = async () => { + await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/branchDoesNotExist', + ); + }; + await expect(fnGithub).rejects.toThrow(NotFoundError); + }); + + it('should throw error when apiBaseUrl is missing', () => { + expect(() => { + /* eslint-disable no-new */ + new GitlabUrlReader( + { + host: 'gitlab.mycompany.com', + }, + { treeResponseFactory }, + ); + }).toThrowError('must configure an explicit apiBaseUrl'); + }); }); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index d51e8dc090..5e02a9b8e6 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -21,16 +21,17 @@ import { readGitLabIntegrationConfigs, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import { InputError, NotFoundError } from '../errors'; +import parseGitUrl from 'git-url-parse'; +import { Readable } from 'stream'; +import { NotFoundError, NotModifiedError } from '../errors'; import { ReadTreeResponseFactory } from './tree'; import { ReaderFactory, ReadTreeOptions, ReadTreeResponse, + SearchResponse, UrlReader, } from './types'; -import parseGitUri from 'git-url-parse'; -import { Readable } from 'stream'; export class GitlabUrlReader implements UrlReader { private readonly treeResponseFactory: ReadTreeResponseFactory; @@ -39,26 +40,32 @@ export class GitlabUrlReader implements UrlReader { const configs = readGitLabIntegrationConfigs( config.getOptionalConfigArray('integrations.gitlab') ?? [], ); - return configs.map(options => { - const reader = new GitlabUrlReader(options, { treeResponseFactory }); - const predicate = (url: URL) => url.host === options.host; + return configs.map(provider => { + const reader = new GitlabUrlReader(provider, { treeResponseFactory }); + const predicate = (url: URL) => url.host === provider.host; return { reader, predicate }; }); }; constructor( - private readonly options: GitLabIntegrationConfig, + private readonly config: GitLabIntegrationConfig, deps: { treeResponseFactory: ReadTreeResponseFactory }, ) { this.treeResponseFactory = deps.treeResponseFactory; + + if (!config.apiBaseUrl) { + throw new Error( + `GitLab integration for '${config.host}' must configure an explicit apiBaseUrl`, + ); + } } async read(url: string): Promise { - const builtUrl = await getGitLabFileFetchUrl(url, this.options); + const builtUrl = await getGitLabFileFetchUrl(url, this.config); let response: Response; try { - response = await fetch(builtUrl, getGitLabRequestOptions(this.options)); + response = await fetch(builtUrl, getGitLabRequestOptions(this.config)); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -78,45 +85,82 @@ export class GitlabUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const { - name: repoName, - ref, - protocol, - resource, - full_name, - filepath, - } = parseGitUri(url); + const { ref, full_name, filepath } = parseGitUrl(url); - if (!ref) { - throw new InputError( - 'GitLab URL must contain a branch to be able to fetch its tree', - ); - } - - const archive = `${protocol}://${resource}/${full_name}/-/archive/${ref}/${repoName}-${ref}.zip`; - const response = await fetch( - archive, - getGitLabRequestOptions(this.options), + // Use GitLab API to get the default branch + // encodeURIComponent is required for GitLab API + // https://docs.gitlab.com/ee/api/README.html#namespaced-path-encoding + const projectGitlabResponse = await fetch( + new URL( + `${this.config.apiBaseUrl}/projects/${encodeURIComponent(full_name)}`, + ).toString(), + getGitLabRequestOptions(this.config), ); - if (!response.ok) { - const msg = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; - if (response.status === 404) { + if (!projectGitlabResponse.ok) { + const msg = `Failed to read tree from ${url}, ${projectGitlabResponse.status} ${projectGitlabResponse.statusText}`; + if (projectGitlabResponse.status === 404) { throw new NotFoundError(msg); } throw new Error(msg); } + const projectGitlabResponseJson = await projectGitlabResponse.json(); - const path = filepath ? `${repoName}-${ref}/${filepath}/` : ''; + // ref is an empty string if no branch is set in provided url to readTree. + const branch = ref || projectGitlabResponseJson.default_branch; - return this.treeResponseFactory.fromZipArchive({ - stream: (response.body as unknown) as Readable, - path, + // Fetch the latest commit in the provided or default branch to compare against + // the provided sha. + const branchGitlabResponse = await fetch( + new URL( + `${this.config.apiBaseUrl}/projects/${encodeURIComponent( + full_name, + )}/repository/branches/${branch}`, + ).toString(), + getGitLabRequestOptions(this.config), + ); + if (!branchGitlabResponse.ok) { + const message = `Failed to read tree (branch) from ${url}, ${branchGitlabResponse.status} ${branchGitlabResponse.statusText}`; + if (branchGitlabResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + const commitSha = (await branchGitlabResponse.json()).commit.id; + + if (options?.etag && options.etag === commitSha) { + throw new NotModifiedError(); + } + + // https://docs.gitlab.com/ee/api/repositories.html#get-file-archive + const archiveGitLabResponse = await fetch( + `${this.config.apiBaseUrl}/projects/${encodeURIComponent( + full_name, + )}/repository/archive.zip?sha=${branch}`, + getGitLabRequestOptions(this.config), + ); + if (!archiveGitLabResponse.ok) { + const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`; + if (archiveGitLabResponse.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + return await this.treeResponseFactory.fromZipArchive({ + stream: (archiveGitLabResponse.body as unknown) as Readable, + subpath: filepath, + etag: commitSha, filter: options?.filter, }); } + async search(): Promise { + throw new Error('GitlabUrlReader does not implement search'); + } + toString() { - const { host, token } = this.options; + const { host, token } = this.config; 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 index 465c125fda..91f4ae63ab 100644 --- a/packages/backend-common/src/reading/UrlReaderPredicateMux.ts +++ b/packages/backend-common/src/reading/UrlReaderPredicateMux.ts @@ -14,29 +14,22 @@ * limitations under the License. */ +import { NotAllowedError } from '../errors'; import { ReadTreeOptions, ReadTreeResponse, + SearchOptions, + SearchResponse, 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); @@ -51,32 +44,37 @@ export class UrlReaderPredicateMux implements UrlReader { } } - if (this.fallback) { - return this.fallback.read(url); - } - - throw new Error(`No reader found that could handle '${url}'`); + throw new NotAllowedError(`Reading from '${url}' is not allowed`); } - readTree(url: string, options?: ReadTreeOptions): Promise { + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { const parsed = new URL(url); for (const { predicate, reader } of this.readers) { if (predicate(parsed)) { - return reader.readTree(url, options); + return await reader.readTree(url, options); } } - if (this.fallback) { - return this.fallback.readTree(url, options); + throw new NotAllowedError(`Reading from '${url}' is not allowed`); + } + + async search(url: string, options?: SearchOptions): Promise { + const parsed = new URL(url); + + for (const { predicate, reader } of this.readers) { + if (predicate(parsed)) { + return await reader.search(url, options); + } } - throw new Error(`No reader found that could handle '${url}'`); + throw new NotAllowedError(`Reading from '${url}' is not allowed`); } toString() { - return `predicateMux{readers=${this.readers - .map(t => t.reader) - .join(',')},fallback=${this.fallback}}`; + return `predicateMux{readers=${this.readers.map(t => t.reader).join(',')}`; } } diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index 2bb5617907..2f233463bb 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -22,8 +22,8 @@ import { AzureUrlReader } from './AzureUrlReader'; import { BitbucketUrlReader } from './BitbucketUrlReader'; import { GithubUrlReader } from './GithubUrlReader'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { FetchUrlReader } from './FetchUrlReader'; import { ReadTreeResponseFactory } from './tree'; +import { FetchUrlReader } from './FetchUrlReader'; type CreateOptions = { /** Root config object */ @@ -32,8 +32,6 @@ type CreateOptions = { 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; }; /** @@ -43,13 +41,8 @@ export class UrlReaders { /** * Creates a UrlReader without any known types. */ - static create({ - logger, - config, - factories, - fallback, - }: CreateOptions): UrlReader { - const mux = new UrlReaderPredicateMux({ fallback: fallback }); + static create({ logger, config, factories }: CreateOptions): UrlReader { + const mux = new UrlReaderPredicateMux(); const treeResponseFactory = ReadTreeResponseFactory.create({ config }); for (const factory of factories ?? []) { @@ -67,10 +60,8 @@ export class UrlReaders { * 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) { + static default({ logger, config, factories = [] }: CreateOptions) { return UrlReaders.create({ logger, config, @@ -79,8 +70,8 @@ export class UrlReaders { BitbucketUrlReader.factory, GithubUrlReader.factory, GitlabUrlReader.factory, + FetchUrlReader.factory, ]), - fallback: fallback ?? new FetchUrlReader(), }); } } diff --git a/packages/backend-common/src/reading/__fixtures__/backstage-mock-etag123.tar.gz b/packages/backend-common/src/reading/__fixtures__/backstage-mock-etag123.tar.gz new file mode 100644 index 0000000000..e1ac2579de Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/backstage-mock-etag123.tar.gz differ diff --git a/packages/backend-common/src/reading/__fixtures__/gitlab-archive.zip b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.zip new file mode 100644 index 0000000000..884ec20004 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/gitlab-archive.zip differ diff --git a/packages/backend-common/src/reading/__fixtures__/mock-main.tar.gz b/packages/backend-common/src/reading/__fixtures__/mock-main.tar.gz new file mode 100644 index 0000000000..291690b447 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/mock-main.tar.gz differ diff --git a/packages/backend-common/src/reading/__fixtures__/mock-main.zip b/packages/backend-common/src/reading/__fixtures__/mock-main.zip new file mode 100644 index 0000000000..beee59d3a0 Binary files /dev/null and b/packages/backend-common/src/reading/__fixtures__/mock-main.zip differ diff --git a/packages/backend-common/src/reading/__fixtures__/repo.tar.gz b/packages/backend-common/src/reading/__fixtures__/repo.tar.gz deleted file mode 100644 index 7a8e9902a2..0000000000 Binary files a/packages/backend-common/src/reading/__fixtures__/repo.tar.gz and /dev/null differ diff --git a/packages/backend-common/src/reading/__fixtures__/repo.zip b/packages/backend-common/src/reading/__fixtures__/repo.zip deleted file mode 100644 index f66bf2d612..0000000000 Binary files a/packages/backend-common/src/reading/__fixtures__/repo.zip and /dev/null differ diff --git a/packages/backend-common/src/reading/index.ts b/packages/backend-common/src/reading/index.ts index 8a6c2bf1a3..1266161ddf 100644 --- a/packages/backend-common/src/reading/index.ts +++ b/packages/backend-common/src/reading/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export type { UrlReader, ReadTreeResponse } from './types'; +export type { UrlReader, ReadTreeResponse, SearchResponse } from './types'; export { UrlReaders } from './UrlReaders'; export { AzureUrlReader } from './AzureUrlReader'; export { BitbucketUrlReader } from './BitbucketUrlReader'; diff --git a/packages/backend-common/src/reading/integration.test.ts b/packages/backend-common/src/reading/integration.test.ts new file mode 100644 index 0000000000..16fdf3ff58 --- /dev/null +++ b/packages/backend-common/src/reading/integration.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { getVoidLogger } from '../logging'; +import { UrlReaders } from './UrlReaders'; + +const reader = UrlReaders.default({ + logger: getVoidLogger(), + config: new ConfigReader({ + // The tokens in this config provide read only access to the backstage-verification repos + integrations: { + github: [ + { + host: 'github.com', + token: + process.env.INTEGRATION_TEST_GITHUB_TOKEN || + `${86}af${617}d9c3c8bf958b37a${630691452765}bb0b0a`, + }, + ], + gitlab: [ + { + host: 'gitlab.com', + token: + process.env.INTEGRATION_TEST_GITLAB_TOKEN || 'tveGtSHDBJM9ZRHZNRfm', + }, + ], + bitbucket: [ + { + host: 'bitbucket.org', + username: 'backstage-verification', + appPassword: + process.env.INTEGRATION_TEST_BITBUCKET_TOKEN || + 'H79MAAhtbZwCafkVTrrQ', + }, + ], + azure: [ + { + host: 'dev.azure.com', + // lasts until 2022-01-28 + token: + process.env.INTEGRATION_TEST_AZURE_TOKEN || + `myvyavvfojh6wvw4ose4bfywqttqx${5}z${5}zs${5}bdxauqaek3yinkazq`, + }, + ], + }, + }), +}); + +function withRetries(count: number, fn: () => Promise) { + return async () => { + let error; + for (let i = 0; i < count; i++) { + try { + await fn(); + return; + } catch (err) { + error = err; + } + } + if (!error.message.match(/rate limit|Too Many Requests/)) { + throw error; + } else { + console.warn('Request was rate limited', error); + } + }; +} + +describe('UrlReaders', () => { + jest.setTimeout(30_000); + + it( + 'should read data from azure', + withRetries(3, async () => { + const data = await reader.read( + 'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2Ftemplate.yaml', + ); + expect(data.toString()).toContain('test-template-azure'); + + const res = await reader.readTree( + 'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2F{{cookiecutter.name}}', + ); + const files = await res.files(); + expect(files).toEqual([ + { + path: 'catalog-info.yaml', + content: expect.any(Function), + }, + ]); + }), + ); + + it( + 'should read data from gitlab', + withRetries(3, async () => { + const data = await reader.read( + 'https://gitlab.com/backstage-verification/test-templates/-/blob/master/template.yaml', + ); + expect(data.toString()).toContain('test-template-gitlab'); + + const res = await reader.readTree( + 'https://gitlab.com/backstage-verification/test-templates/-/tree/master/{{cookiecutter.name}}', + ); + const files = await res.files(); + expect(files).toEqual([ + { + path: 'catalog-info.yaml', + content: expect.any(Function), + }, + ]); + }), + ); + + it( + 'should read data from bitbucket', + withRetries(3, async () => { + const data = await reader.read( + 'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml', + ); + expect(data.toString()).toContain('test-template-bitbucket'); + + const res = await reader.readTree( + 'https://bitbucket.org/backstage-verification/test-template/src/master/{{cookiecutter.name}}', + ); + const files = await res.files(); + expect(files).toEqual([ + { + path: 'catalog-info.yaml', + content: expect.any(Function), + }, + ]); + }), + ); + + it( + 'should read data from github', + withRetries(3, async () => { + const data = await reader.read( + 'https://github.com/backstage-verification/test-templates/blob/master/template.yaml', + ); + expect(data.toString()).toContain('test-template-github'); + + const res = await reader.readTree( + 'https://github.com/backstage-verification/test-templates/tree/master/{{cookiecutter.name}}', + ); + const files = await res.files(); + expect(files).toEqual([ + { + path: 'catalog-info.yaml', + content: expect.any(Function), + }, + ]); + }), + ); +}); diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts index 986a0302bc..05ecdb0fc3 100644 --- a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts +++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts @@ -24,8 +24,11 @@ import { ZipArchiveResponse } from './ZipArchiveResponse'; type FromArchiveOptions = { // A binary stream of a tar archive. stream: Readable; - // If set, the root of the tree will be set to the given directory path. - path?: string; + // If unset, the files at the root of the tree will be read. + // subpath must not contain the name of the top level directory. + subpath?: string; + // etag of the blob + etag: string; // Filter passed on from the ReadTreeOptions filter?: (path: string) => boolean; }; @@ -43,8 +46,9 @@ export class ReadTreeResponseFactory { async fromTarArchive(options: FromArchiveOptions): Promise { return new TarArchiveResponse( options.stream, - options.path ?? '', + options.subpath ?? '', this.workDir, + options.etag, options.filter, ); } @@ -52,8 +56,9 @@ export class ReadTreeResponseFactory { async fromZipArchive(options: FromArchiveOptions): Promise { return new ZipArchiveResponse( options.stream, - options.path ?? '', + options.subpath ?? '', this.workDir, + options.etag, options.filter, ); } diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts index 1bc0d3a386..2b76bea9e3 100644 --- a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts +++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts @@ -20,7 +20,7 @@ import { resolve as resolvePath } from 'path'; import { TarArchiveResponse } from './TarArchiveResponse'; const archiveData = fs.readFileSync( - resolvePath(__filename, '../../__fixtures__/repo.tar.gz'), + resolvePath(__filename, '../../__fixtures__/mock-main.tar.gz'), ); describe('TarArchiveResponse', () => { @@ -38,7 +38,7 @@ describe('TarArchiveResponse', () => { it('should read files', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp'); + const res = new TarArchiveResponse(stream, '', '/tmp', 'etag'); const files = await res.files(); expect(files).toEqual([ @@ -61,7 +61,7 @@ describe('TarArchiveResponse', () => { it('should read files with filter', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp', path => + const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path => path.endsWith('.yml'), ); const files = await res.files(); @@ -79,14 +79,14 @@ describe('TarArchiveResponse', () => { it('should read as archive and files', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp'); + const res = new TarArchiveResponse(stream, '', '/tmp', 'etag'); const buffer = await res.archive(); await expect(res.archive()).rejects.toThrow( 'Response has already been read', ); - const res2 = new TarArchiveResponse(buffer, '', '/tmp'); + const res2 = new TarArchiveResponse(buffer, '', '/tmp', 'etag'); const files = await res2.files(); expect(files).toEqual([ @@ -109,21 +109,20 @@ describe('TarArchiveResponse', () => { it('should extract entire archive into directory', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse(stream, '', '/tmp'); + const res = new TarArchiveResponse(stream, '', '/tmp', 'etag'); const dir = await res.dir(); - await expect( - fs.readFile(resolvePath(dir, 'mock-repo/mkdocs.yml'), 'utf8'), + fs.readFile(resolvePath(dir, 'mkdocs.yml'), 'utf8'), ).resolves.toBe('site_name: Test\n'); await expect( - fs.readFile(resolvePath(dir, 'mock-repo/docs/index.md'), 'utf8'), + fs.readFile(resolvePath(dir, 'docs/index.md'), 'utf8'), ).resolves.toBe('# Test\n'); }); it('should extract archive into directory with a subpath', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse(stream, 'mock-repo/docs/', '/tmp'); + const res = new TarArchiveResponse(stream, 'docs', '/tmp', 'etag'); const dir = await res.dir(); expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/); @@ -135,7 +134,7 @@ describe('TarArchiveResponse', () => { it('should extract archive into directory with a subpath and filter', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp', path => + const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path => path.endsWith('.yml'), ); const dir = await res.dir({ targetDir: '/tmp' }); diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts index 5d18ec7dc6..d22529293f 100644 --- a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts @@ -14,22 +14,26 @@ * limitations under the License. */ -import tar, { Parse, ParseStream, ReadEntry } from 'tar'; -import path from 'path'; -import fs from 'fs-extra'; -import { Readable, pipeline as pipelineCb } from 'stream'; -import { promisify } from 'util'; import concatStream from 'concat-stream'; +import fs from 'fs-extra'; +import platformPath from 'path'; +import { pipeline as pipelineCb, Readable } from 'stream'; +import tar, { Parse, ParseStream, ReadEntry } from 'tar'; +import { promisify } from 'util'; import { ReadTreeResponse, - ReadTreeResponseFile, ReadTreeResponseDirOptions, + ReadTreeResponseFile, } from '../types'; // Tar types for `Parse` is not a proper constructor, but it should be const TarParseStream = (Parse as unknown) as { new (): ParseStream }; const pipeline = promisify(pipelineCb); +// Matches a directory name + one `/` at the start of any string, +// containing any character except `/` one or more times, and ending with a `/` +// e.g. Will match `dirA/` in `dirA/dirB/file.ext` +const directoryNameRegex = /^[^\/]+\//; /** * Wraps a tar archive stream into a tree response reader. @@ -41,6 +45,7 @@ export class TarArchiveResponse implements ReadTreeResponse { private readonly stream: Readable, private readonly subPath: string, private readonly workDir: string, + public readonly etag: string, private readonly filter?: (path: string) => boolean, ) { if (subPath) { @@ -53,6 +58,8 @@ export class TarArchiveResponse implements ReadTreeResponse { ); } } + + this.etag = etag; } // Make sure the input stream is only read once @@ -75,14 +82,18 @@ export class TarArchiveResponse implements ReadTreeResponse { return; } + // File path relative to the root extracted directory. Will remove the + // top level dir name from the path since its name is hard to predetermine. + const relativePath = entry.path.replace(directoryNameRegex, ''); + if (this.subPath) { - if (!entry.path.startsWith(this.subPath)) { + if (!relativePath.startsWith(this.subPath)) { entry.resume(); return; } } - const path = entry.path.slice(this.subPath.length); + const path = relativePath.slice(this.subPath.length); if (this.filter) { if (!this.filter(path)) { entry.resume(); @@ -94,7 +105,10 @@ export class TarArchiveResponse implements ReadTreeResponse { await pipeline(entry, concatStream(resolve)); }); - files.push({ path, content: () => content }); + files.push({ + path, + content: () => content, + }); entry.resume(); }); @@ -133,9 +147,11 @@ export class TarArchiveResponse implements ReadTreeResponse { const dir = options?.targetDir ?? - (await fs.mkdtemp(path.join(this.workDir, 'backstage-'))); + (await fs.mkdtemp(platformPath.join(this.workDir, 'backstage-'))); - const strip = this.subPath ? this.subPath.split('/').length - 1 : 0; + // Equivalent of tar --strip-components=N + // When no subPath is given, remove just 1 top level directory + const strip = this.subPath ? this.subPath.split('/').length : 1; await pipeline( this.stream, @@ -143,7 +159,10 @@ export class TarArchiveResponse implements ReadTreeResponse { strip, cwd: dir, filter: path => { - if (this.subPath && !path.startsWith(this.subPath)) { + // File path relative to the root extracted directory. Will remove the + // top level dir name from the path since its name is hard to predetermine. + const relativePath = path.replace(directoryNameRegex, ''); + if (this.subPath && !relativePath.startsWith(this.subPath)) { return false; } if (this.filter) { diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts index 6c2592ffce..3bcaa5e0e3 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts @@ -20,7 +20,7 @@ import { resolve as resolvePath } from 'path'; import { ZipArchiveResponse } from './ZipArchiveResponse'; const archiveData = fs.readFileSync( - resolvePath(__filename, '../../__fixtures__/repo.zip'), + resolvePath(__filename, '../../__fixtures__/mock-main.zip'), ); describe('ZipArchiveResponse', () => { @@ -38,30 +38,30 @@ describe('ZipArchiveResponse', () => { it('should read files', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp'); + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag'); const files = await res.files(); expect(files).toEqual([ { - path: 'docs/index.md', + path: 'mkdocs.yml', content: expect.any(Function), }, { - path: 'mkdocs.yml', + path: 'docs/index.md', content: expect.any(Function), }, ]); const contents = await Promise.all(files.map(f => f.content())); expect(contents.map(c => c.toString('utf8').trim())).toEqual([ - '# Test', 'site_name: Test', + '# Test', ]); }); it('should read files with filter', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp', path => + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path => path.endsWith('.yml'), ); const files = await res.files(); @@ -79,51 +79,51 @@ describe('ZipArchiveResponse', () => { it('should read as archive and files', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp'); + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag'); const buffer = await res.archive(); await expect(res.archive()).rejects.toThrow( 'Response has already been read', ); - const res2 = new ZipArchiveResponse(buffer, '', '/tmp'); + const res2 = new ZipArchiveResponse(buffer, '', '/tmp', 'etag'); const files = await res2.files(); expect(files).toEqual([ { - path: 'docs/index.md', + path: 'mkdocs.yml', content: expect.any(Function), }, { - path: 'mkdocs.yml', + path: 'docs/index.md', content: expect.any(Function), }, ]); const contents = await Promise.all(files.map(f => f.content())); expect(contents.map(c => c.toString('utf8').trim())).toEqual([ - '# Test', 'site_name: Test', + '# Test', ]); }); it('should extract entire archive into directory', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse(stream, '', '/tmp'); + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag'); const dir = await res.dir(); await expect( - fs.readFile(resolvePath(dir, 'mock-repo/mkdocs.yml'), 'utf8'), + fs.readFile(resolvePath(dir, 'mkdocs.yml'), 'utf8'), ).resolves.toBe('site_name: Test\n'); await expect( - fs.readFile(resolvePath(dir, 'mock-repo/docs/index.md'), 'utf8'), + fs.readFile(resolvePath(dir, 'docs/index.md'), 'utf8'), ).resolves.toBe('# Test\n'); }); it('should extract archive into directory with a subpath', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse(stream, 'mock-repo/docs/', '/tmp'); + const res = new ZipArchiveResponse(stream, 'docs/', '/tmp', 'etag'); const dir = await res.dir(); expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/); @@ -135,7 +135,7 @@ describe('ZipArchiveResponse', () => { it('should extract archive into directory with a subpath and filter', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp', path => + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path => path.endsWith('.yml'), ); const dir = await res.dir({ targetDir: '/tmp' }); diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts index 4106d49a11..2547c7b0e4 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts @@ -14,17 +14,22 @@ * limitations under the License. */ -import path from 'path'; -import fs from 'fs-extra'; -import unzipper, { Entry } from 'unzipper'; import archiver from 'archiver'; +import fs from 'fs-extra'; +import platformPath from 'path'; import { Readable } from 'stream'; +import unzipper, { Entry } from 'unzipper'; import { ReadTreeResponse, - ReadTreeResponseFile, ReadTreeResponseDirOptions, + ReadTreeResponseFile, } from '../types'; +// Matches a directory name + one `/` at the start of any string, +// containing any character except / one or more times, and ending with a `/` +// e.g. Will match `dirA/` in `dirA/dirB/file.ext` +const directoryNameRegex = /^[^\/]+\//; + /** * Wraps a zip archive stream into a tree response reader. */ @@ -35,6 +40,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { private readonly stream: Readable, private readonly subPath: string, private readonly workDir: string, + public readonly etag: string, private readonly filter?: (path: string) => boolean, ) { if (subPath) { @@ -47,6 +53,8 @@ export class ZipArchiveResponse implements ReadTreeResponse { ); } } + + this.etag = etag; } // Make sure the input stream is only read once @@ -57,18 +65,26 @@ export class ZipArchiveResponse implements ReadTreeResponse { this.read = true; } - private getPath(entry: Entry): string { - return entry.path.slice(this.subPath.length); + // Will remove the top level dir name from the path since its name is hard to predetermine. + private stripTopDirectory(path: string): string { + return path.replace(directoryNameRegex, ''); + } + + // File path relative to the root extracted directory or a sub directory if subpath is set. + private getInnerPath(path: string): string { + return path.slice(this.subPath.length); } private shouldBeIncluded(entry: Entry): boolean { + const strippedPath = this.stripTopDirectory(entry.path); + if (this.subPath) { - if (!entry.path.startsWith(this.subPath)) { + if (!strippedPath.startsWith(this.subPath)) { return false; } } if (this.filter) { - return this.filter(this.getPath(entry)); + return this.filter(this.getInnerPath(entry.path)); } return true; } @@ -88,7 +104,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { if (this.shouldBeIncluded(entry)) { files.push({ - path: this.getPath(entry), + path: this.getInnerPath(this.stripTopDirectory(entry.path)), content: () => entry.buffer(), }); } else { @@ -112,7 +128,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { .pipe(unzipper.Parse()) .on('entry', (entry: Entry) => { if (entry.type === 'File' && this.shouldBeIncluded(entry)) { - archive.append(entry, { name: this.getPath(entry) }); + archive.append(entry, { name: this.getInnerPath(entry.path) }); } else { entry.autodrain(); } @@ -128,7 +144,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { const dir = options?.targetDir ?? - (await fs.mkdtemp(path.join(this.workDir, 'backstage-'))); + (await fs.mkdtemp(platformPath.join(this.workDir, 'backstage-'))); await this.stream .pipe(unzipper.Parse()) @@ -136,12 +152,14 @@ export class ZipArchiveResponse implements ReadTreeResponse { // Ignore directory entries since we handle that with the file entries // as a zip can have files with directories without directory entries if (entry.type === 'File' && this.shouldBeIncluded(entry)) { - const entryPath = this.getPath(entry); - const dirname = path.dirname(entryPath); + const entryPath = this.getInnerPath( + this.stripTopDirectory(entry.path), + ); + const dirname = platformPath.dirname(entryPath); if (dirname) { - await fs.mkdirp(path.join(dir, dirname)); + await fs.mkdirp(platformPath.join(dir, dirname)); } - entry.pipe(fs.createWriteStream(path.join(dir, entryPath))); + entry.pipe(fs.createWriteStream(platformPath.join(dir, entryPath))); } else { entry.autodrain(); } diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index f9dca3e1d5..4caa76841a 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -18,28 +18,13 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { ReadTreeResponseFactory } from './tree'; -export type ReadTreeOptions = { - /** - * A filter that can be used to select which files should be included. - * - * The path passed to the filter function is the relative path from the URL - * that the file tree is fetched from, without any leading '/'. - * - * For example, given the URL https://github.com/my/repo/tree/master/my-dir, a file - * at https://github.com/my/repo/blob/master/my-dir/my-subdir/my-file.txt will - * be represented as my-subdir/my-file.txt - * - * If no filter is provided all files are extracted. - */ - filter?(path: string): boolean; -}; - /** * A generic interface for fetching plain data from URLs. */ export type UrlReader = { read(url: string): Promise; readTree(url: string, options?: ReadTreeOptions): Promise; + search(url: string, options?: SearchOptions): Promise; }; export type UrlReaderPredicateTuple = { @@ -57,9 +42,54 @@ export type ReaderFactory = (options: { treeResponseFactory: ReadTreeResponseFactory; }) => UrlReaderPredicateTuple[]; -export type ReadTreeResponseFile = { - path: string; - content(): Promise; +/** + * An options object for readTree operations. + */ +export type ReadTreeOptions = { + /** + * A filter that can be used to select which files should be included. + * + * The path passed to the filter function is the relative path from the URL + * that the file tree is fetched from, without any leading '/'. + * + * For example, given the URL https://github.com/my/repo/tree/master/my-dir, a file + * at https://github.com/my/repo/blob/master/my-dir/my-subdir/my-file.txt will + * be represented as my-subdir/my-file.txt + * + * If no filter is provided all files are extracted. + */ + filter?(path: string): boolean; + + /** + * An etag can be provided to check whether readTree's response has changed from a previous execution. + * + * In the readTree() response, an etag is returned along with the tree blob. The etag is a unique identifer + * of the tree blob, usually the commit SHA or etag from the target. + * + * When a etag is given in ReadTreeOptions, readTree will first compare the etag against the etag + * on the target branch. If they match, readTree will throw a NotModifiedError indicating that the readTree + * response will not differ from the previous response which included this particular etag. If they mismatch, + * readTree will return the rest of ReadTreeResponse along with a new etag. + */ + etag?: string; +}; + +export type ReadTreeResponse = { + /** + * files() returns an array of all the files inside the tree and corresponding functions to read their content. + */ + files(): Promise; + archive(): Promise; + + /** + * dir() extracts the tree response into a directory and returns the path of the directory. + */ + dir(options?: ReadTreeResponseDirOptions): Promise; + + /** + * A unique identifer of the tree blob, usually the commit SHA or etag from the target. + */ + etag: string; }; export type ReadTreeResponseDirOptions = { @@ -67,8 +97,58 @@ export type ReadTreeResponseDirOptions = { targetDir?: string; }; -export type ReadTreeResponse = { - files(): Promise; - archive(): Promise; - dir(options?: ReadTreeResponseDirOptions): Promise; +/** + * Represents a single file in a readTree response. + */ +export type ReadTreeResponseFile = { + path: string; + content(): Promise; +}; + +/** + * An options object for search operations. + */ +export type SearchOptions = { + /** + * An etag can be provided to check whether the search response has changed from a previous execution. + * + * In the search() response, an etag is returned along with the files. The etag is a unique identifer + * of the current tree, usually the commit SHA or etag from the target. + * + * When an etag is given in SearchOptions, search will first compare the etag against the etag + * on the target branch. If they match, search will throw a NotModifiedError indicating that the search + * response will not differ from the previous response which included this particular etag. If they mismatch, + * search will return the rest of SearchResponse along with a new etag. + */ + etag?: string; +}; + +/** + * The output of a search operation. + */ +export type SearchResponse = { + /** + * The files that matched the search query. + */ + files: SearchResponseFile[]; + + /** + * A unique identifer of the current remote tree, usually the commit SHA or etag from the target. + */ + etag: string; +}; + +/** + * Represents a single file in a search response. + */ +export type SearchResponseFile = { + /** + * The full URL to the file. + */ + url: string; + + /** + * The binary contents of the file. + */ + content(): Promise; }; diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts index b0a1df6541..5b24d23b99 100644 --- a/packages/backend-common/src/scm/git.ts +++ b/packages/backend-common/src/scm/git.ts @@ -86,13 +86,22 @@ export class Git { return git.commit({ fs, dir, message, author, committer }); } - async clone({ url, dir }: { url: string; dir: string }): Promise { + async clone({ + url, + dir, + ref, + }: { + url: string; + dir: string; + ref?: string; + }): Promise { this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`); return git.clone({ fs, http, url, dir, + ref, singleBranch: true, depth: 1, onProgress: this.onProgressHandler(), diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index 4547c41b7b..3a33675d5f 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -22,23 +22,8 @@ export type BaseOptions = { listenHost?: string; }; -export type CertificateOptions = { - key?: CertificateKeyOptions; - attributes?: CertificateAttributeOptions; -}; - -export type CertificateKeyOptions = { - size?: number; - algorithm?: string; - days?: number; -}; - -export type CertificateAttributeOptions = { - commonName?: string; -}; - export type HttpsSettings = { - certificate: CertificateSigningOptions | CertificateReferenceOptions; + certificate: CertificateGenerationOptions | CertificateReferenceOptions; }; export type CertificateReferenceOptions = { @@ -46,11 +31,8 @@ export type CertificateReferenceOptions = { cert: string; }; -export type CertificateSigningOptions = { - algorithm?: string; - size?: number; - days?: number; - attributes: CertificateAttributes; +export type CertificateGenerationOptions = { + hostname: string; }; export type CertificateAttributes = { @@ -196,20 +178,14 @@ export function readHttpsSettings(config: Config): HttpsSettings | undefined { const https = config.getOptional('https'); if (https === true) { const baseUrl = config.getString('baseUrl'); - let commonName; + let hostname; try { - commonName = new URL(baseUrl).hostname; + hostname = new URL(baseUrl).hostname; } catch (error) { throw new Error(`Invalid backend.baseUrl "${baseUrl}"`); } - return { - certificate: { - attributes: { - commonName, - }, - }, - }; + return { certificate: { hostname } }; } const cc = config.getOptionalConfig('https'); diff --git a/packages/backend-common/src/service/lib/hostFactory.ts b/packages/backend-common/src/service/lib/hostFactory.ts index 656f160c31..db202a84ab 100644 --- a/packages/backend-common/src/service/lib/hostFactory.ts +++ b/packages/backend-common/src/service/lib/hostFactory.ts @@ -20,10 +20,12 @@ import express from 'express'; import * as http from 'http'; import * as https from 'https'; import { Logger } from 'winston'; -import { CertificateSigningOptions, HttpsSettings } from './config'; +import { HttpsSettings } from './config'; const ALMOST_MONTH_IN_MS = 25 * 24 * 60 * 60 * 1000; +const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/; + /** * Creates a Http server instance based on an Express application. * @@ -59,17 +61,17 @@ export async function createHttpsServer( let credentials: { key: string | Buffer; cert: string | Buffer }; - const signingOptions: any = httpsSettings?.certificate; - - // TODO(Rugvip): remove support for generated certificate params and make this a more straightforward check - if (signingOptions?.attributes) { - credentials = await getGeneratedCertificate(signingOptions, logger); + if ('hostname' in httpsSettings?.certificate) { + credentials = await getGeneratedCertificate( + httpsSettings.certificate.hostname, + logger, + ); } else { logger?.info('Loading certificate from config'); credentials = { - key: signingOptions?.key, - cert: signingOptions?.cert, + key: httpsSettings?.certificate?.key, + cert: httpsSettings?.certificate?.cert, }; } @@ -80,16 +82,7 @@ export async function createHttpsServer( return https.createServer(credentials, app) as http.Server; } -async function getGeneratedCertificate( - options: CertificateSigningOptions, - logger?: Logger, -) { - if (options?.algorithm) { - logger?.warn( - 'Certificate generation configuration with parameters in backend.https.certificate is deprecated, set backend.https = true instead', - ); - } - +async function getGeneratedCertificate(hostname: string, logger?: Logger) { const hasModules = await fs.pathExists('node_modules'); let certPath; if (hasModules) { @@ -119,20 +112,61 @@ async function getGeneratedCertificate( } logger?.info('Generating new self-signed certificate'); - const newCert = await createCertificate(options); + const newCert = await createCertificate(hostname); await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8'); return newCert; } -async function createCertificate(options: CertificateSigningOptions) { - const attributes: Array = Object.entries( - options.attributes, - ).map(([name, value]) => ({ name, value })); +async function createCertificate(hostname: string) { + const attributes = [ + { + name: 'commonName', + value: 'dev-cert', + }, + ]; + + const sans = [ + { + type: 2, // DNS + value: 'localhost', + }, + { + type: 2, + value: 'localhost.localdomain', + }, + { + type: 2, + value: '[::1]', + }, + { + type: 7, // IP + ip: '127.0.0.1', + }, + { + type: 7, + ip: 'fe80::1', + }, + ]; + + // Add hostname from backend.baseUrl if it doesn't already exist in our list of SANs + if (!sans.find(({ value, ip }) => value === hostname || ip === hostname)) { + sans.push( + IP_HOSTNAME_REGEX.test(hostname) + ? { + type: 7, + ip: hostname, + } + : { + type: 2, + value: hostname, + }, + ); + } const params = { - algorithm: options?.algorithm || 'sha256', - keySize: options?.size || 2048, - days: options?.days || 30, + algorithm: 'sha256', + keySize: 2048, + days: 30, extensions: [ { name: 'keyUsage', @@ -151,36 +185,7 @@ async function createCertificate(options: CertificateSigningOptions) { }, { name: 'subjectAltName', - altNames: [ - { - type: 2, // DNS - value: 'localhost', - }, - { - type: 2, - value: 'localhost.localdomain', - }, - { - type: 2, - value: '[::1]', - }, - { - type: 7, // IP - ip: '127.0.0.1', - }, - { - type: 7, - ip: 'fe80::1', - }, - ...(options.attributes.commonName - ? [ - { - type: 2, // DNS - value: options.attributes.commonName, - }, - ] - : []), - ], + altNames: sans, }, ], }; diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 04cac6c2fc..f85c22784d 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,125 @@ # example-backend +## 0.2.14 + +### Patch Changes + +- Updated dependencies [c777df180] +- Updated dependencies [2430ee7c2] +- Updated dependencies [3149bfe63] +- Updated dependencies [6e612ce25] +- Updated dependencies [e44925723] +- Updated dependencies [9d6ef14bc] +- Updated dependencies [a26668913] +- Updated dependencies [025e122c3] +- Updated dependencies [e9aab60c7] +- Updated dependencies [24e47ef1e] +- Updated dependencies [7881f2117] +- Updated dependencies [529d16d27] +- Updated dependencies [cdea0baf1] +- Updated dependencies [11cb5ef94] + - @backstage/plugin-techdocs-backend@0.5.5 + - @backstage/backend-common@0.5.2 + - @backstage/plugin-catalog-backend@0.6.0 + - @backstage/catalog-model@0.7.1 + - example-app@0.2.14 + - @backstage/plugin-scaffolder-backend@0.6.0 + - @backstage/plugin-app-backend@0.3.6 + +## 0.2.13 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [681111228] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [234e7d985] +- Updated dependencies [d7b1d317f] +- Updated dependencies [a91aa6bf2] +- Updated dependencies [39b05b9ae] +- Updated dependencies [4eaa06057] + - @backstage/backend-common@0.5.1 + - @backstage/plugin-scaffolder-backend@0.5.2 + - @backstage/plugin-kubernetes-backend@0.2.6 + - @backstage/plugin-catalog-backend@0.5.5 + - @backstage/plugin-kafka-backend@0.2.0 + - @backstage/plugin-auth-backend@0.2.12 + - example-app@0.2.13 + - @backstage/plugin-app-backend@0.3.5 + +## 0.2.12 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [d54857099] +- Updated dependencies [0b135e7e0] +- Updated dependencies [318a6af9f] +- Updated dependencies [294a70cab] +- Updated dependencies [ac7be581a] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [ed6baab66] +- Updated dependencies [ad838c02f] +- Updated dependencies [a5e27d5c1] +- Updated dependencies [0643a3336] +- Updated dependencies [a2291d7cc] +- Updated dependencies [f9ba00a1c] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/plugin-catalog-backend@0.5.4 + - @backstage/plugin-kubernetes-backend@0.2.5 + - @backstage/backend-common@0.5.0 + - @backstage/plugin-scaffolder-backend@0.5.0 + - @backstage/plugin-techdocs-backend@0.5.4 + - @backstage/plugin-auth-backend@0.2.11 + - example-app@0.2.12 + - @backstage/plugin-kafka-backend@0.1.1 + - @backstage/plugin-app-backend@0.3.4 + - @backstage/plugin-graphql-backend@0.1.5 + - @backstage/plugin-proxy-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.7 + +## 0.2.11 + +### Patch Changes + +- cc068c0d6: Bump the gitbeaker dependencies to 28.x. + + To update your own installation, go through the `package.json` files of all of + your packages, and ensure that all dependencies on `@gitbeaker/node` or + `@gitbeaker/core` are at version `^28.0.2`. Then run `yarn install` at the root + of your repo. + +- Updated dependencies [68ad5af51] +- Updated dependencies [5a9a7e7c2] +- Updated dependencies [f3b064e1c] +- Updated dependencies [94fdf4955] +- Updated dependencies [cc068c0d6] +- Updated dependencies [ade6b3bdf] +- Updated dependencies [468579734] +- Updated dependencies [cb7af51e7] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] +- Updated dependencies [711ba55a2] + - @backstage/plugin-techdocs-backend@0.5.3 + - @backstage/plugin-kubernetes-backend@0.2.4 + - @backstage/catalog-model@0.6.1 + - @backstage/plugin-catalog-backend@0.5.3 + - @backstage/plugin-scaffolder-backend@0.4.1 + - @backstage/plugin-auth-backend@0.2.10 + - @backstage/backend-common@0.4.3 + +## 0.2.10 + +### Patch Changes + +- Updated dependencies [5eb8c9b9e] +- Updated dependencies [7e3451700] + - @backstage/plugin-scaffolder-backend@0.4.0 + ## 0.2.8 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 5c13ee94e2..544bb976fe 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.8", + "version": "0.2.14", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,23 +27,24 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/plugin-app-backend": "^0.3.3", - "@backstage/plugin-auth-backend": "^0.2.7", - "@backstage/plugin-catalog-backend": "^0.5.1", - "@backstage/plugin-graphql-backend": "^0.1.4", - "@backstage/plugin-kubernetes-backend": "^0.2.3", - "@backstage/plugin-proxy-backend": "^0.2.3", - "@backstage/plugin-rollbar-backend": "^0.1.5", - "@backstage/plugin-scaffolder-backend": "^0.3.6", - "@backstage/plugin-techdocs-backend": "^0.5.0", - "@gitbeaker/node": "^25.2.0", - "@octokit/rest": "^18.0.0", + "@backstage/plugin-app-backend": "^0.3.6", + "@backstage/plugin-auth-backend": "^0.2.12", + "@backstage/plugin-catalog-backend": "^0.6.0", + "@backstage/plugin-graphql-backend": "^0.1.5", + "@backstage/plugin-kubernetes-backend": "^0.2.6", + "@backstage/plugin-kafka-backend": "^0.2.0", + "@backstage/plugin-proxy-backend": "^0.2.4", + "@backstage/plugin-rollbar-backend": "^0.1.7", + "@backstage/plugin-scaffolder-backend": "^0.6.0", + "@backstage/plugin-techdocs-backend": "^0.5.5", + "@gitbeaker/node": "^28.0.2", + "@octokit/rest": "^18.0.12", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.8", + "example-app": "^0.2.14", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", @@ -53,11 +54,10 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.3", + "@backstage/cli": "^0.6.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", - "@types/express-serve-static-core": "^4.17.5", - "@types/helmet": "^0.0.48" + "@types/express-serve-static-core": "^4.17.5" }, "files": [ "dist" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 68cc170901..81d0bb96d2 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -38,6 +38,7 @@ import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; import kubernetes from './plugins/kubernetes'; +import kafka from './plugins/kafka'; import rollbar from './plugins/rollbar'; import scaffolder from './plugins/scaffolder'; import proxy from './plugins/proxy'; @@ -77,6 +78,7 @@ async function main() { const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); + const kafkaEnv = useHotMemoize(module, () => createEnv('kafka')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); const appEnv = useHotMemoize(module, () => createEnv('app')); @@ -87,6 +89,7 @@ async function main() { apiRouter.use('/auth', await auth(authEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); + apiRouter.use('/kafka', await kafka(kafkaEnv)); apiRouter.use('/proxy', await proxy(proxyEnv)); apiRouter.use('/graphql', await graphql(graphqlEnv)); apiRouter.use(notFoundHandler()); diff --git a/packages/backend/src/plugins/kafka.ts b/packages/backend/src/plugins/kafka.ts new file mode 100644 index 0000000000..e65ce6719c --- /dev/null +++ b/packages/backend/src/plugins/kafka.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouter } from '@backstage/plugin-kafka-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); +} diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 5d36d508a5..723165ff82 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -30,10 +30,12 @@ import Docker from 'dockerode'; export default async function createPlugin({ logger, config, + database, }: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); @@ -53,5 +55,6 @@ export default async function createPlugin({ config, dockerClient, entityClient, + database, }); } diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index c99f72e376..c8042b72e9 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/catalog-client +## 0.3.5 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + ## 0.3.4 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index c878119a35..00b05e37bf 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.6.0", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index f6c2477d6c..8d581dcb0c 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,55 @@ # @backstage/catalog-model +## 0.7.1 + +### Patch Changes + +- 6e612ce25: Adds a new optional `links` metadata field to the Entity class within the `catalog-model` package (as discussed in [[RFC] Entity Links](https://github.com/backstage/backstage/issues/3787)). This PR adds support for the entity links only. Follow up PR's will introduce the UI component to display them. +- 025e122c3: Replace `yup` with `ajv`, for validation of catalog entities. +- 7881f2117: Introduce json schema variants of the `yup` validation schemas +- 11cb5ef94: Implement matchEntityWithRef for client side filtering of entities by ref matching + +## 0.7.0 + +### Minor Changes + +- a93f42213: The catalog no longer attempts to merge old and new annotations, when updating an entity from a remote location. This was a behavior that was copied from kubernetes, and catered to use cases where you wanted to use HTTP POST to update an entity in-place, outside of what the refresh loop does. This has proved to be a mistake, because as a side effect, the refresh loop effectively is unable to ever delete annotations when they are removed from source YAML. This is obviously a breaking change, but we believe that this is not a behavior that is relied upon in the wild, and it has never been an actually supported use flow of the catalog. We therefore choose to break the behavior outright, and instead just store updated annotations verbatim - just like we already do for example for labels + +### Patch Changes + +- def2307f3: Adds a `backstage.io/managed-by-origin-location` annotation to all entities. It links to the + location that was registered to the catalog and which emitted this entity. It has a different + semantic than the existing `backstage.io/managed-by-location` annotation, which tells the direct + parent location that created this entity. + + Consider this example: The Backstage operator adds a location of type `github-org` in the + `app-config.yaml`. This setting will be added to a `bootstrap:boostrap` location. The processor + discovers the entities in the following branch + `Location bootstrap:bootstrap -> Location github-org:… -> User xyz`. The user `xyz` will be: + + ```yaml + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: xyz + annotations: + # This entity was added by the 'github-org:…' location + backstage.io/managed-by-location: github-org:… + # The entity was added because the 'bootstrap:boostrap' was added to the catalog + backstage.io/managed-by-origin-location: bootstrap:bootstrap + # ... + spec: + # ... + ``` + +## 0.6.1 + +### Patch Changes + +- f3b064e1c: Export the `schemaValidator` helper function. +- abbee6fff: Implement System, Domain and Resource entity kinds. +- 147fadcb9: Add subcomponentOf to Component kind to represent subsystems of larger components. + ## 0.6.0 ### Minor Changes diff --git a/packages/catalog-model/examples/acme/backstage-group.yaml b/packages/catalog-model/examples/acme/backstage-group.yaml index 3aecfa0023..f83587532c 100644 --- a/packages/catalog-model/examples/acme/backstage-group.yaml +++ b/packages/catalog-model/examples/acme/backstage-group.yaml @@ -8,6 +8,6 @@ spec: profile: displayName: Backstage email: backstage@example.com - picture: https://example.com/groups/backstage.jpeg + picture: https://avatars.dicebear.com/api/identicon/backstage@example.com.svg?background=%23fff&margin=25 parent: infrastructure children: [team-a, team-b] diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml index 2b44183bc5..9a0d690b57 100644 --- a/packages/catalog-model/examples/acme/org.yaml +++ b/packages/catalog-model/examples/acme/org.yaml @@ -3,12 +3,17 @@ kind: Group metadata: name: acme-corp description: The acme-corp organization + links: + - url: http://www.acme.com/ + title: Website + - url: https://meta.wikimedia.org/wiki/ + title: Intranet spec: type: organization profile: displayName: ACME Corp email: info@example.com - picture: https://example.com/logo.jpeg + picture: https://avatars.dicebear.com/api/identicon/info@example.com.svg?background=%23fff&margin=25 children: [infrastructure] --- apiVersion: backstage.io/v1alpha1 diff --git a/packages/catalog-model/examples/acme/team-a-group.yaml b/packages/catalog-model/examples/acme/team-a-group.yaml index ed736dce50..e343209d5f 100644 --- a/packages/catalog-model/examples/acme/team-a-group.yaml +++ b/packages/catalog-model/examples/acme/team-a-group.yaml @@ -8,7 +8,7 @@ spec: profile: # Intentional no displayName for testing email: team-a@example.com - picture: https://example.com/groups/team-a.jpeg + picture: https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25 parent: backstage children: [] --- @@ -20,7 +20,7 @@ spec: profile: # Intentional no displayName for testing email: breanna-davison@example.com - picture: https://example.com/staff/breanna.jpeg + picture: https://avatars.dicebear.com/api/avataaars/breanna-davison@example.com.svg?background=%23fff memberOf: [team-a] --- apiVersion: backstage.io/v1alpha1 @@ -31,7 +31,7 @@ spec: profile: displayName: Janelle Dawe email: janelle-dawe@example.com - picture: https://example.com/staff/janelle.jpeg + picture: https://avatars.dicebear.com/api/avataaars/janelle-dawe@example.com.svg?background=%23fff memberOf: [team-a] --- apiVersion: backstage.io/v1alpha1 @@ -42,7 +42,7 @@ spec: profile: displayName: Nigel Manning email: nigel-manning@example.com - picture: https://example.com/staff/nigel.jpeg + picture: https://avatars.dicebear.com/api/avataaars/nigel-manning@example.com.svg?background=%23fff memberOf: [team-a] --- # This user is added as an example, to make it more easy for the "Guest" @@ -56,5 +56,5 @@ spec: profile: displayName: Guest User email: guest@example.com - picture: https://example.com/staff/the-ceos-dog.jpeg + picture: https://avatars.dicebear.com/api/avataaars/guest@example.com.svg?background=%23fff memberOf: [team-a] diff --git a/packages/catalog-model/examples/acme/team-b-group.yaml b/packages/catalog-model/examples/acme/team-b-group.yaml index 9ed50b04c2..20ab8721ea 100644 --- a/packages/catalog-model/examples/acme/team-b-group.yaml +++ b/packages/catalog-model/examples/acme/team-b-group.yaml @@ -8,7 +8,7 @@ spec: profile: displayName: Team B email: team-b@example.com - picture: https://example.com/groups/team-b.jpeg + picture: https://avatars.dicebear.com/api/identicon/team-b@example.com.svg?background=%23fff&margin=25 parent: backstage children: [] --- @@ -20,7 +20,7 @@ spec: profile: displayName: Amelia Park email: amelia-park@example.com - picture: https://example.com/staff/amelia.jpeg + picture: https://avatars.dicebear.com/api/avataaars/amelia-park@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -31,7 +31,7 @@ spec: profile: displayName: Colette Brock email: colette-brock@example.com - picture: https://example.com/staff/colette.jpeg + picture: https://avatars.dicebear.com/api/avataaars/colette-brock@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -42,7 +42,7 @@ spec: profile: displayName: Jenny Doe email: jenny-doe@example.com - picture: https://example.com/staff/jenny.jpeg + picture: https://avatars.dicebear.com/api/avataaars/jenny-doe@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -53,7 +53,7 @@ spec: profile: displayName: Jonathon Page email: jonathon-page@example.com - picture: https://example.com/staff/jonathon.jpeg + picture: https://avatars.dicebear.com/api/avataaars/jonathon-page@example.com.svg?background=%23fff memberOf: [team-b] --- apiVersion: backstage.io/v1alpha1 @@ -64,5 +64,5 @@ spec: profile: displayName: Justine Barrow email: justine-barrow@example.com - picture: https://example.com/staff/justine.jpeg + picture: https://avatars.dicebear.com/api/avataaars/justine-barrow@example.com.svg?background=%23fff memberOf: [team-b] diff --git a/packages/catalog-model/examples/acme/team-c-group.yaml b/packages/catalog-model/examples/acme/team-c-group.yaml index 7f151dbb1e..639b591d61 100644 --- a/packages/catalog-model/examples/acme/team-c-group.yaml +++ b/packages/catalog-model/examples/acme/team-c-group.yaml @@ -8,7 +8,7 @@ spec: profile: displayName: Team C email: team-c@example.com - picture: https://example.com/groups/team-c.jpeg + picture: https://avatars.dicebear.com/api/identicon/team-c@example.com.svg?background=%23fff&margin=25 parent: boxoffice children: [] --- @@ -20,7 +20,7 @@ spec: profile: displayName: Calum Leavy email: calum-leavy@example.com - picture: https://example.com/staff/calum.jpeg + picture: https://avatars.dicebear.com/api/avataaars/calum-leavy@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -31,7 +31,7 @@ spec: profile: displayName: Frank Tiernan email: frank-tiernan@example.com - picture: https://example.com/staff/frank.jpeg + picture: https://avatars.dicebear.com/api/avataaars/frank-tiernan@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -42,7 +42,7 @@ spec: profile: displayName: Peadar MacMahon email: peadar-macmahon@example.com - picture: https://example.com/staff/peadar.jpeg + picture: https://avatars.dicebear.com/api/avataaars/peadar-macmahon@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -53,7 +53,7 @@ spec: profile: displayName: Sarah Gilroy email: sarah-gilroy@example.com - picture: https://example.com/staff/sarah.jpeg + picture: https://avatars.dicebear.com/api/avataaars/sarah-gilroy@example.com.svg?background=%23fff memberOf: [team-c] --- apiVersion: backstage.io/v1alpha1 @@ -64,5 +64,5 @@ spec: profile: displayName: Tara MacGovern email: tara-macgovern@example.com - picture: https://example.com/staff/tara.jpeg + picture: https://avatars.dicebear.com/api/avataaars/tara-macgovern@example.com.svg?background=%23fff memberOf: [team-c] diff --git a/packages/catalog-model/examples/acme/team-d-group.yaml b/packages/catalog-model/examples/acme/team-d-group.yaml index a0d3c2351a..898ed8be6c 100644 --- a/packages/catalog-model/examples/acme/team-d-group.yaml +++ b/packages/catalog-model/examples/acme/team-d-group.yaml @@ -8,7 +8,7 @@ spec: profile: displayName: Team D email: team-d@example.com - picture: https://example.com/groups/team-d.jpeg + picture: https://avatars.dicebear.com/api/identicon/team-d@example.com.svg?background=%23fff&margin=25 parent: boxoffice children: [] --- @@ -20,7 +20,7 @@ spec: profile: displayName: Eva MacDowell email: eva-macdowell@example.com - picture: https://example.com/staff/eva.jpeg + picture: https://avatars.dicebear.com/api/avataaars/eva-macdowell@example.com.svg?background=%23fff memberOf: [team-d] --- apiVersion: backstage.io/v1alpha1 @@ -31,5 +31,5 @@ spec: profile: displayName: Lucy Sheehan email: lucy-sheehan@example.com - picture: https://example.com/staff/lucy.jpeg + picture: https://avatars.dicebear.com/api/avataaars/lucy-sheehan@example.com.svg?background=%23fff memberOf: [team-d] diff --git a/packages/catalog-model/examples/all-components.yaml b/packages/catalog-model/examples/all-components.yaml index 5db5825b20..9471b810a1 100644 --- a/packages/catalog-model/examples/all-components.yaml +++ b/packages/catalog-model/examples/all-components.yaml @@ -15,4 +15,6 @@ spec: - ./components/www-artist-component.yaml - ./components/shuffle-api-component.yaml - ./components/wayback-archive-component.yaml + - ./components/wayback-archive-ingestion-component.yaml + - ./components/wayback-archive-storage-component.yaml - ./components/wayback-search-component.yaml diff --git a/packages/catalog-model/examples/all-domains.yaml b/packages/catalog-model/examples/all-domains.yaml new file mode 100644 index 0000000000..91a8a5b76d --- /dev/null +++ b/packages/catalog-model/examples/all-domains.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-domains + description: A collection of all Backstage example domains +spec: + targets: + - ./domains/artists-domain.yaml + - ./domains/playback-domain.yaml diff --git a/packages/catalog-model/examples/all-resources.yaml b/packages/catalog-model/examples/all-resources.yaml new file mode 100644 index 0000000000..d0986e3fe2 --- /dev/null +++ b/packages/catalog-model/examples/all-resources.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-resources + description: A collection of all Backstage example resources +spec: + targets: + - ./resources/artists-db-resource.yaml diff --git a/packages/catalog-model/examples/all-systems.yaml b/packages/catalog-model/examples/all-systems.yaml new file mode 100644 index 0000000000..165bee54e5 --- /dev/null +++ b/packages/catalog-model/examples/all-systems.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-systems + description: A collection of all Backstage example systems +spec: + targets: + - ./systems/artist-engagement-portal-system.yaml + - ./systems/audio-playback-system.yaml + - ./systems/podcast-system.yaml diff --git a/packages/catalog-model/examples/apis/petstore-api.yaml b/packages/catalog-model/examples/apis/petstore-api.yaml index a314b4e255..5b6878f7d3 100644 --- a/packages/catalog-model/examples/apis/petstore-api.yaml +++ b/packages/catalog-model/examples/apis/petstore-api.yaml @@ -6,6 +6,13 @@ metadata: tags: - store - rest + links: + - url: https://github.com/swagger-api/swagger-petstore + title: GitHub Repo + icon: github + - url: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore.yaml + title: API Spec + icon: code spec: type: openapi lifecycle: experimental diff --git a/packages/catalog-model/examples/apis/streetlights-api.yaml b/packages/catalog-model/examples/apis/streetlights-api.yaml index 725811d0cc..4e0dbb1396 100644 --- a/packages/catalog-model/examples/apis/streetlights-api.yaml +++ b/packages/catalog-model/examples/apis/streetlights-api.yaml @@ -5,6 +5,10 @@ metadata: description: The Smartylighting Streetlights API allows you to remotely manage the city lights. tags: - mqtt + links: + - url: https://github.com/asyncapi/asyncapi/blob/master/examples/1.2.0/streetlights.yml + title: Source Code + icon: code spec: type: asyncapi lifecycle: production diff --git a/packages/catalog-model/examples/apis/swapi-graphql.yaml b/packages/catalog-model/examples/apis/swapi-graphql.yaml index 0c1f7af5a4..f3d924fc53 100644 --- a/packages/catalog-model/examples/apis/swapi-graphql.yaml +++ b/packages/catalog-model/examples/apis/swapi-graphql.yaml @@ -3,6 +3,10 @@ kind: API metadata: name: starwars-graphql description: SWAPI GraphQL Schema + links: + - url: https://github.com/graphql/swapi-graphql + title: GitHub Repo + icon: github spec: type: graphql lifecycle: production diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index 257344be3d..fbf28d7fcb 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -6,7 +6,14 @@ metadata: tags: - java - data + links: + - url: https://example.com/apm/artists-lookup + title: APM + icon: dashboard + - url: https://example.com/logs/artists-lookup + title: Logs spec: type: service lifecycle: experimental owner: team-a + system: artist-engagement-portal diff --git a/packages/catalog-model/examples/components/petstore-component.yaml b/packages/catalog-model/examples/components/petstore-component.yaml index acbb2f82b0..48286f9b74 100644 --- a/packages/catalog-model/examples/components/petstore-component.yaml +++ b/packages/catalog-model/examples/components/petstore-component.yaml @@ -3,6 +3,10 @@ kind: Component metadata: name: petstore description: Petstore + links: + - url: https://github.com/swagger-api/swagger-petstore + title: GitHub Repo + icon: github spec: type: service lifecycle: experimental diff --git a/packages/catalog-model/examples/components/playback-lib-component.yaml b/packages/catalog-model/examples/components/playback-lib-component.yaml index f7d7670b5d..de7e93d38d 100644 --- a/packages/catalog-model/examples/components/playback-lib-component.yaml +++ b/packages/catalog-model/examples/components/playback-lib-component.yaml @@ -7,3 +7,4 @@ spec: type: library lifecycle: experimental owner: team-c + system: audio-playback diff --git a/packages/catalog-model/examples/components/playback-order-component.yaml b/packages/catalog-model/examples/components/playback-order-component.yaml index c4f41b2b58..9146063886 100644 --- a/packages/catalog-model/examples/components/playback-order-component.yaml +++ b/packages/catalog-model/examples/components/playback-order-component.yaml @@ -10,3 +10,4 @@ spec: type: service lifecycle: production owner: user:guest + system: audio-playback diff --git a/packages/catalog-model/examples/components/podcast-api-component.yaml b/packages/catalog-model/examples/components/podcast-api-component.yaml index b89ff48c48..30d254a00f 100644 --- a/packages/catalog-model/examples/components/podcast-api-component.yaml +++ b/packages/catalog-model/examples/components/podcast-api-component.yaml @@ -9,3 +9,4 @@ spec: type: service lifecycle: experimental owner: team-b + system: podcast diff --git a/packages/catalog-model/examples/components/queue-proxy-component.yaml b/packages/catalog-model/examples/components/queue-proxy-component.yaml index 7f7fcbd527..a2d5ae5ea4 100644 --- a/packages/catalog-model/examples/components/queue-proxy-component.yaml +++ b/packages/catalog-model/examples/components/queue-proxy-component.yaml @@ -10,3 +10,4 @@ spec: type: website lifecycle: production owner: team-b + system: podcast diff --git a/packages/catalog-model/examples/components/shuffle-api-component.yaml b/packages/catalog-model/examples/components/shuffle-api-component.yaml index 1c2da03511..6328ebdf3b 100644 --- a/packages/catalog-model/examples/components/shuffle-api-component.yaml +++ b/packages/catalog-model/examples/components/shuffle-api-component.yaml @@ -9,3 +9,4 @@ spec: type: service lifecycle: production owner: user:guest + system: audio-playback diff --git a/packages/catalog-model/examples/components/wayback-archive-ingestion-component.yaml b/packages/catalog-model/examples/components/wayback-archive-ingestion-component.yaml new file mode 100644 index 0000000000..4f870ac831 --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-archive-ingestion-component.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-archive-ingestion + description: Ingestion subsystem of the Wayback Archive +spec: + type: service + lifecycle: production + owner: team-d + subcomponentOf: wayback-archive diff --git a/packages/catalog-model/examples/components/wayback-archive-storage-component.yaml b/packages/catalog-model/examples/components/wayback-archive-storage-component.yaml new file mode 100644 index 0000000000..78d67258d7 --- /dev/null +++ b/packages/catalog-model/examples/components/wayback-archive-storage-component.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: wayback-archive-storage + description: Storage subsystem of the Wayback Archive +spec: + type: service + lifecycle: production + owner: team-a + subcomponentOf: wayback-archive diff --git a/packages/catalog-model/examples/components/www-artist-component.yaml b/packages/catalog-model/examples/components/www-artist-component.yaml index c333eb8c09..3acb6fc6a6 100644 --- a/packages/catalog-model/examples/components/www-artist-component.yaml +++ b/packages/catalog-model/examples/components/www-artist-component.yaml @@ -7,3 +7,4 @@ spec: type: website lifecycle: production owner: team-a + system: artist-engagement-portal diff --git a/packages/catalog-model/examples/domains/artists-domain.yaml b/packages/catalog-model/examples/domains/artists-domain.yaml new file mode 100644 index 0000000000..2a74425791 --- /dev/null +++ b/packages/catalog-model/examples/domains/artists-domain.yaml @@ -0,0 +1,13 @@ +apiVersion: backstage.io/v1alpha1 +kind: Domain +metadata: + name: artists + description: Everything related to artists + links: + - url: http://example.com/domain/artists/ + title: Domain Readme + - url: http://example.com/domains/artists/dashboard + title: Domain Metrics Dashboard + icon: dashboard +spec: + owner: team-a diff --git a/packages/catalog-model/examples/domains/playback-domain.yaml b/packages/catalog-model/examples/domains/playback-domain.yaml new file mode 100644 index 0000000000..c9933ebf5e --- /dev/null +++ b/packages/catalog-model/examples/domains/playback-domain.yaml @@ -0,0 +1,7 @@ +apiVersion: backstage.io/v1alpha1 +kind: Domain +metadata: + name: playback + description: Everything related to audio playback +spec: + owner: user:frank.tiernan diff --git a/packages/catalog-model/examples/resources/artists-db-resource.yaml b/packages/catalog-model/examples/resources/artists-db-resource.yaml new file mode 100644 index 0000000000..a666e9b3fd --- /dev/null +++ b/packages/catalog-model/examples/resources/artists-db-resource.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Resource +metadata: + name: artists-db + description: Stores artist details +spec: + type: database + owner: team-a + system: artist-engagement-portal diff --git a/packages/catalog-model/examples/systems/artist-engagement-portal-system.yaml b/packages/catalog-model/examples/systems/artist-engagement-portal-system.yaml new file mode 100644 index 0000000000..8de3c00880 --- /dev/null +++ b/packages/catalog-model/examples/systems/artist-engagement-portal-system.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: artist-engagement-portal + description: Everything related to artists + tags: + - portal +spec: + owner: team-a + domain: artists diff --git a/packages/catalog-model/examples/systems/audio-playback-system.yaml b/packages/catalog-model/examples/systems/audio-playback-system.yaml new file mode 100644 index 0000000000..7430ae2ff5 --- /dev/null +++ b/packages/catalog-model/examples/systems/audio-playback-system.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: audio-playback + description: Audio playback system +spec: + owner: team-c + domain: playback diff --git a/packages/catalog-model/examples/systems/podcast-system.yaml b/packages/catalog-model/examples/systems/podcast-system.yaml new file mode 100644 index 0000000000..47a2f7ac9f --- /dev/null +++ b/packages/catalog-model/examples/systems/podcast-system.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: podcast + description: Podcast playback +spec: + owner: team-b + domain: playback diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 4fad95e122..af74b78a08 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.6.0", + "version": "0.7.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,13 +32,14 @@ "@backstage/config": "^0.1.2", "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.8", + "ajv": "^7.0.3", "json-schema": "^0.2.5", "lodash": "^4.17.15", "uuid": "^8.0.0", "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.6.0", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/EntityPolicies.test.ts b/packages/catalog-model/src/EntityPolicies.test.ts index cd869f798c..b67180241e 100644 --- a/packages/catalog-model/src/EntityPolicies.test.ts +++ b/packages/catalog-model/src/EntityPolicies.test.ts @@ -1,4 +1,3 @@ -import { Entity } from './entity'; /* * Copyright 2020 Spotify AB * @@ -15,8 +14,8 @@ import { Entity } from './entity'; * limitations under the License. */ +import { Entity, EntityPolicy } from './entity'; import { EntityPolicies } from './EntityPolicies'; -import { EntityPolicy } from './types'; describe('EntityPolicies', () => { const p1: jest.Mocked = { enforce: jest.fn() }; diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts index 1f304f8f27..2d576b1670 100644 --- a/packages/catalog-model/src/EntityPolicies.ts +++ b/packages/catalog-model/src/EntityPolicies.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { Entity } from './entity'; -import { EntityPolicy } from './types'; +import { Entity, EntityPolicy } from './entity'; // Helper that requires that all of a set of policies can be successfully // applied diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index ac43137546..7abe1f9c0c 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -125,6 +125,11 @@ export type EntityMeta = JsonObject & { * various ways. */ tags?: string[]; + + /** + * A list of external hyperlinks related to the entity. + */ + links?: EntityLink[]; }; /** @@ -161,3 +166,23 @@ export type EntityRelationSpec = { */ target: EntityName; }; + +/** + * A link to external information that is related to the entity. + */ +export type EntityLink = { + /** + * The url to the external site, document, etc. + */ + url: string; + + /** + * An optional descriptive title for the link. + */ + title?: string; + + /** + * An optional semantic key that represents a visual icon. + */ + icon?: string; +}; diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index d450119f0a..e80e14f7a4 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -20,12 +20,14 @@ export { } from './constants'; export type { Entity, + EntityLink, EntityMeta, EntityRelation, EntityRelationSpec, } from './Entity'; export * from './policies'; export { + compareEntityToRef, getEntityName, parseEntityName, parseEntityRef, diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts index 3119164454..4f5bbe04f4 100644 --- a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts @@ -15,7 +15,7 @@ */ import lodash from 'lodash'; -import { EntityPolicy } from '../../types'; +import { EntityPolicy } from './types'; import { ENTITY_DEFAULT_NAMESPACE } from '../constants'; import { Entity } from '../Entity'; diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts index edf04a64cc..33ee045c1e 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts @@ -38,6 +38,10 @@ describe('FieldFormatEntityPolicy', () => { tags: - java - data-service + links: + - url: https://example.org + title: Website + icon: website spec: custom: stuff `); @@ -110,4 +114,110 @@ describe('FieldFormatEntityPolicy', () => { data.metadata.tags.push('Hello World'); await expect(policy.enforce(data)).rejects.toThrow(/tags.*"Hello World"/i); }); + + it('accepts missing links', async () => { + delete data.metadata.links; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('accepts empty links array', async () => { + data.metadata.links = []; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('accepts multiple links', async () => { + data.metadata.links = [{ url: 'http://foo' }, { url: 'https://bar' }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('rejects missing link url value', async () => { + data.metadata.links = [{}]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0.url/i); + }); + + it('rejects a single bad missing link url value', async () => { + data.metadata.links = [{ url: 'http://good' }, { url: '' }]; + await expect(policy.enforce(data)).rejects.toThrow( + /links.1.url.*valid url/i, + ); + }); + + it('rejects empty link url value', async () => { + data.metadata.links = [{ url: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0.url.*/i); + }); + + it('rejects bad link url value', async () => { + data.metadata.links = [{ url: 'invalid' }]; + await expect(policy.enforce(data)).rejects.toThrow( + /links.0.url.*"invalid"/i, + ); + }); + + it('accepts missing link title', async () => { + data.metadata.links = [{ url: 'http://foo', icon: 'dashboard' }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('rejects empty link title', async () => { + data.metadata.links = [{ url: 'http://foo', title: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*""/i); + }); + + it('rejects bad link title', async () => { + data.metadata.links = [{ url: 'http://foo', title: 123 }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*"123"/i); + }); + + it.each([[123], [{}], [[]]])( + 'rejects bad link title %s', + async (title: unknown) => { + data.metadata.links = [{ url: 'http://foo', title }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*/i); + }, + ); + + it('rejects a single bad link title', async () => { + data.metadata.links = [ + { url: 'http://foo', title: 'good' }, + { url: 'http://foo', title: '' }, + ]; + await expect(policy.enforce(data)).rejects.toThrow(/links.1.title.*""/i); + }); + + it('accepts missing link icon', async () => { + data.metadata.links = [{ url: 'http://foo', title: 'foo' }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('rejects empty link icon', async () => { + data.metadata.links = [{ url: 'http://foo', icon: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*""/i); + }); + + it.each([['dashboard'], ['admin-dashboard'], ['foo_dashboard']])( + 'accepts valid link icon', + async icon => { + data.metadata.links = [{ url: 'http://foo', icon }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }, + ); + + it.each([[123], [{}], [[]], ['abc xyz']])( + 'rejects bad link icon value %s', + async (icon: unknown) => { + data.metadata.links = [{ url: 'http://foo', icon }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*/i); + }, + ); + + it('rejects a single bad link icon value', async () => { + data.metadata.links = [ + { url: 'http://foo', icon: 'good' }, + { url: 'http://foo', icon: 'not good' }, + ]; + await expect(policy.enforce(data)).rejects.toThrow( + /links.1.icon.*"not good"/i, + ); + }); }); diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index 0aabbd9e48..1ed13c972c 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EntityPolicy } from '../../types'; +import { EntityPolicy } from './types'; import { CommonValidatorFunctions, KubernetesValidatorFunctions, @@ -83,6 +83,12 @@ export class FieldFormatEntityPolicy implements EntityPolicy { expectation = 'a string that is a sequence of [a-zA-Z][a-z0-9A-Z], at most 63 characters in total'; break; + case 'isValidUrl': + expectation = 'a string that is a valid url'; + break; + case 'isValidString': + expectation = 'a non empty string'; + break; default: expectation = undefined; break; @@ -134,6 +140,23 @@ export class FieldFormatEntityPolicy implements EntityPolicy { require(`tags.${i}`, tags[i], this.validators.isValidTag); } + const links = entity.metadata.links ?? []; + + for (let i = 0; i < links.length; ++i) { + require(`links.${i}.url`, links[i] + ?.url, CommonValidatorFunctions.isValidUrl); + optional( + `links.${i}.title`, + links[i]?.title, + CommonValidatorFunctions.isValidString, + ); + optional( + `links.${i}.icon`, + links[i]?.icon, + KubernetesValidatorFunctions.isValidObjectName, + ); + } + return entity; } } diff --git a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts index 9d1851bc02..7d401542ba 100644 --- a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EntityPolicy } from '../../types'; +import { EntityPolicy } from './types'; import { Entity } from '../Entity'; const defaultKnownFields = ['apiVersion', 'kind', 'metadata', 'spec']; diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts index d63ba49dbe..c84bdbdd38 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts @@ -39,6 +39,10 @@ describe('SchemaValidEntityPolicy', () => { tags: - java - data + links: + - url: https://example.com + title: Website + icon: website spec: custom: stuff `); @@ -198,6 +202,24 @@ describe('SchemaValidEntityPolicy', () => { await expect(policy.enforce(data)).rejects.toThrow(/tags/); }); + it('accepts missing links', async () => { + delete data.metadata.links; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('accepts empty links array', async () => { + data.metadata.links = []; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it.each([['invalid type'], [123], [{}], [{ url: 'https://foo' }]])( + 'rejects bad links type %s', + async (val: unknown) => { + data.metadata.links = val; + await expect(policy.enforce(data)).rejects.toThrow(/links/); + }, + ); + // // spec // diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts index 4ff73e9a49..7e0a8df268 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts @@ -14,30 +14,12 @@ * limitations under the License. */ -import * as yup from 'yup'; -import { EntityPolicy } from '../../types'; +import Ajv, { ValidateFunction } from 'ajv'; +import entitySchema from '../../schema/Entity.schema.json'; +import entityMetaSchema from '../../schema/EntityMeta.schema.json'; +import commonSchema from '../../schema/shared/common.schema.json'; import { Entity } from '../Entity'; - -const DEFAULT_ENTITY_SCHEMA = yup - .object({ - apiVersion: yup.string().required(), - kind: yup.string().required(), - metadata: yup - .object({ - uid: yup.string().notRequired().min(1), - etag: yup.string().notRequired().min(1), - generation: yup.number().notRequired().integer().min(1), - name: yup.string().required(), - namespace: yup.string().notRequired(), - description: yup.string().notRequired(), - labels: yup.object>().notRequired(), - annotations: yup.object>().notRequired(), - tags: yup.array().notRequired(), - }) - .required(), - spec: yup.object({}).notRequired(), - }) - .required(); +import { EntityPolicy } from './types'; /** * Ensures that the entity spec is valid according to a schema. @@ -47,17 +29,28 @@ const DEFAULT_ENTITY_SCHEMA = yup * typescript type. */ export class SchemaValidEntityPolicy implements EntityPolicy { - private readonly schema: yup.Schema; - - constructor(schema: yup.Schema = DEFAULT_ENTITY_SCHEMA) { - this.schema = schema; - } + private validate: ValidateFunction | undefined; async enforce(entity: Entity): Promise { - try { - return await this.schema.validate(entity, { strict: true }); - } catch (e) { - throw new Error(`Malformed envelope, ${e}`); + if (!this.validate) { + const ajv = new Ajv({ allowUnionTypes: true }); + this.validate = ajv + .addSchema([commonSchema, entityMetaSchema], undefined, undefined, true) + .compile(entitySchema); } + + const result = this.validate(entity); + if (result === true) { + return entity; + } + + const [error] = this.validate.errors || []; + if (!error) { + throw new Error(`Malformed envelope, Unknown error`); + } + + throw new Error( + `Malformed envelope, ${error.dataPath || ''} ${error.message}`, + ); } } diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts index c381d29a23..5d75ef4d84 100644 --- a/packages/catalog-model/src/entity/policies/index.ts +++ b/packages/catalog-model/src/entity/policies/index.ts @@ -18,3 +18,4 @@ export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy'; export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy'; export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy'; export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy'; +export type { EntityPolicy } from './types'; diff --git a/packages/catalog-model/src/entity/policies/types.ts b/packages/catalog-model/src/entity/policies/types.ts new file mode 100644 index 0000000000..415c98bbd2 --- /dev/null +++ b/packages/catalog-model/src/entity/policies/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { Entity } from '../Entity'; + +/** + * A policy for validation or mutation to be applied to entities as they are + * entering the system. + */ +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, or + * undefined if this processor could not handle the entity + * @throws An error if the entity should be rejected + */ + enforce(entity: Entity): Promise; +}; diff --git a/packages/catalog-model/src/entity/ref.test.ts b/packages/catalog-model/src/entity/ref.test.ts index bd7cc9477d..5ca511ee03 100644 --- a/packages/catalog-model/src/entity/ref.test.ts +++ b/packages/catalog-model/src/entity/ref.test.ts @@ -16,7 +16,12 @@ import { ENTITY_DEFAULT_NAMESPACE } from './constants'; import { Entity } from './Entity'; -import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref'; +import { + compareEntityToRef, + parseEntityName, + parseEntityRef, + serializeEntityRef, +} from './ref'; describe('ref', () => { describe('parseEntityName', () => { @@ -381,4 +386,320 @@ describe('ref', () => { ).toEqual({ kind: 'a', namespace: 'b', name: 'c/x' }); }); }); + + describe('compareEntityToRef', () => { + const entityWithNamespace: Entity = { + apiVersion: 'a', + kind: 'K', + metadata: { + name: 'n', + namespace: 'ns', + }, + }; + const entityWithoutNamespace: Entity = { + apiVersion: 'a', + kind: 'K', + metadata: { + name: 'n', + }, + }; + + it('handles matching string refs', () => { + expect(compareEntityToRef(entityWithNamespace, 'K:ns/n')).toBe(true); + expect(compareEntityToRef(entityWithNamespace, 'k:nS/N')).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'K:n', { + defaultNamespace: 'ns', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'K:n', { + defaultNamespace: 'Ns', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'ns/n', { defaultKind: 'K' }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'n', { + defaultKind: 'K', + defaultNamespace: 'ns', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'N', { + defaultKind: 'k', + defaultNamespace: 'nS', + }), + ).toBe(true); + + expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe( + true, + ); + expect(compareEntityToRef(entityWithoutNamespace, 'K:deFault/n')).toBe( + true, + ); + expect( + compareEntityToRef(entityWithoutNamespace, 'K:n', { + defaultNamespace: 'default', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, 'K:n', { + defaultNamespace: 'deFault', + }), + ).toBe(true); + expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe( + true, + ); + expect(compareEntityToRef(entityWithoutNamespace, 'K:n')).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, 'default/n', { + defaultKind: 'K', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, 'n', { + defaultKind: 'K', + defaultNamespace: 'default', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, 'n', { + defaultKind: 'K', + }), + ).toBe(true); + }); + + it('handles mismatching string refs', () => { + expect(compareEntityToRef(entityWithNamespace, 'X:ns/n')).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, 'ns/n', { + defaultKind: 'X', + }), + ).toBe(false); + + expect(compareEntityToRef(entityWithNamespace, 'K:xx/n')).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, 'K:n', { + defaultNamespace: 'xx', + }), + ).toBe(false); + + expect(compareEntityToRef(entityWithNamespace, 'K:ns/x')).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, 'x', { + defaultKind: 'K', + defaultNamespace: 'ns', + }), + ).toBe(false); + }); + + it('handles matching compound refs', () => { + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'K', + namespace: 'ns', + name: 'n', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'k', + namespace: 'Ns', + name: 'N', + }), + ).toBe(true); + expect( + compareEntityToRef( + entityWithNamespace, + { kind: 'K', name: 'n' }, + { + defaultNamespace: 'ns', + }, + ), + ).toBe(true); + expect( + compareEntityToRef( + entityWithNamespace, + { namespace: 'ns', name: 'n' }, + { defaultKind: 'K' }, + ), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'n', { + defaultKind: 'K', + defaultNamespace: 'ns', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'N', { + defaultKind: 'k', + defaultNamespace: 'nS', + }), + ).toBe(true); + + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'K', + namespace: 'default', + name: 'n', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'k', + namespace: 'deFault', + name: 'N', + }), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { kind: 'K', name: 'n' }, + { + defaultNamespace: 'default', + }, + ), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, { kind: 'K', name: 'n' }), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { namespace: 'default', name: 'n' }, + { + defaultKind: 'K', + }, + ), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { name: 'n' }, + { + defaultKind: 'K', + defaultNamespace: 'default', + }, + ), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { name: 'N' }, + { + defaultKind: 'k', + defaultNamespace: 'defAult', + }, + ), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { name: 'n' }, + { + defaultKind: 'K', + }, + ), + ).toBe(true); + }); + + it('handles mismatching compound refs', () => { + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'X', + namespace: 'ns', + name: 'n', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithNamespace, + { + namespace: 'ns', + name: 'n', + }, + { defaultKind: 'X' }, + ), + ).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'X', + namespace: 'default', + name: 'n', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithoutNamespace, + { + namespace: 'default', + name: 'n', + }, + { defaultKind: 'X' }, + ), + ).toBe(false); + + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'K', + namespace: 'xx', + name: 'n', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithNamespace, + { + kind: 'K', + name: 'n', + }, + { defaultNamespace: 'xx' }, + ), + ).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'K', + namespace: 'xx', + name: 'n', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithoutNamespace, + { + kind: 'K', + name: 'n', + }, + { defaultNamespace: 'xx' }, + ), + ).toBe(false); + + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'K', + namespace: 'ns', + name: 'x', + }), + ).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'K', + namespace: 'default', + name: 'x', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithoutNamespace, + { + kind: 'K', + name: 'x', + }, + { defaultNamespace: 'default' }, + ), + ).toBe(false); + }); + }); }); diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts index 9f6f175d8d..bf34962d01 100644 --- a/packages/catalog-model/src/entity/ref.ts +++ b/packages/catalog-model/src/entity/ref.ts @@ -18,6 +18,27 @@ import { EntityName, EntityRef } from '../types'; import { ENTITY_DEFAULT_NAMESPACE } from './constants'; import { Entity } from './Entity'; +function parseRefString( + ref: string, +): { + kind?: string; + namespace?: string; + name: string; +} { + const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim()); + if (!match) { + throw new TypeError( + `Entity reference "${ref}" was not on the form [:][/]`, + ); + } + + return { + kind: match[1]?.slice(0, -1), + namespace: match[2]?.slice(0, -1), + name: match[3], + }; +} + /** * Extracts the kind, namespace and name that form the name triplet of the * given entity. @@ -121,17 +142,11 @@ export function parseEntityRef( } if (typeof ref === 'string') { - const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim()); - if (!match) { - throw new Error( - `Entity reference "${ref}" was not on the form [:][/]`, - ); - } - + const parsed = parseRefString(ref); return { - kind: match[1]?.slice(0, -1) ?? context.defaultKind, - namespace: match[2]?.slice(0, -1) ?? context.defaultNamespace, - name: match[3], + kind: parsed.kind ?? context.defaultKind, + namespace: parsed.namespace ?? context.defaultNamespace, + name: parsed.name, }; } @@ -196,3 +211,53 @@ export function serializeEntityRef( return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`; } + +/** + * Compares an entity to either a string reference or a compound reference. + * + * The comparison is case insensitive, and all of kind, namespace, and name + * must match (after applying the optional context to the ref). + * + * @param entity The entity to match + * @param ref A string or compound entity ref + * @param context An optional context of default kind and namespace, that apply + * to the ref if given + * @returns True if matching, false otherwise + */ +export function compareEntityToRef( + entity: Entity, + ref: EntityRef | EntityName, + context?: EntityRefContext, +): boolean { + const entityKind = entity.kind; + const entityNamespace = entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE; + const entityName = entity.metadata.name; + + let refKind: string | undefined; + let refNamespace: string | undefined; + let refName: string; + if (typeof ref === 'string') { + const parsed = parseRefString(ref); + refKind = parsed.kind || context?.defaultKind; + refNamespace = + parsed.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE; + refName = parsed.name; + } else { + refKind = ref.kind || context?.defaultKind; + refNamespace = + ref.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE; + refName = ref.name; + } + + if (!refKind || !refNamespace) { + throw new Error( + `Entity reference or context did not contain kind and namespace`, + ); + } + + return ( + entityKind.toLowerCase() === refKind.toLowerCase() && + entityNamespace.toLowerCase() === refNamespace.toLowerCase() && + entityName.toLowerCase() === refName.toLowerCase() + ); +} diff --git a/packages/catalog-model/src/entity/util.test.ts b/packages/catalog-model/src/entity/util.test.ts index e4399bbe05..c7e2c036b5 100644 --- a/packages/catalog-model/src/entity/util.test.ts +++ b/packages/catalog-model/src/entity/util.test.ts @@ -96,18 +96,12 @@ describe('util', () => { b = lodash.cloneDeep(a); b.metadata.labels.labelKey += 'a'; expect(entityHasChanges(a, b)).toBe(true); - }); - - it('detects annotation changes, but not removals', () => { - let b: any = lodash.cloneDeep(a); + b = lodash.cloneDeep(a); b.metadata.annotations.annotationKey += 'a'; expect(entityHasChanges(a, b)).toBe(true); b = lodash.cloneDeep(a); - b.metadata.annotations.n = 'n'; - expect(entityHasChanges(a, b)).toBe(true); - b = lodash.cloneDeep(a); delete b.metadata.annotations.annotationKey; - expect(entityHasChanges(a, b)).toBe(false); + expect(entityHasChanges(a, b)).toBe(true); }); it('detects spec changes', () => { diff --git a/packages/catalog-model/src/entity/util.ts b/packages/catalog-model/src/entity/util.ts index ed68339a99..2c63cc4c49 100644 --- a/packages/catalog-model/src/entity/util.ts +++ b/packages/catalog-model/src/entity/util.ts @@ -54,10 +54,6 @@ export function generateEntityEtag(): string { * @param next The new state of the entity */ export function entityHasChanges(previous: Entity, next: Entity): boolean { - if (entityHasAnnotationChanges(previous, next)) { - return true; - } - const e1 = lodash.cloneDeep(previous); const e2 = lodash.cloneDeep(next); @@ -67,6 +63,18 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean { if (!e2.metadata.labels) { e2.metadata.labels = {}; } + if (!e1.metadata.annotations) { + e1.metadata.annotations = {}; + } + if (!e2.metadata.annotations) { + e2.metadata.annotations = {}; + } + if (!e1.metadata.tags) { + e1.metadata.tags = []; + } + if (!e2.metadata.tags) { + e2.metadata.tags = []; + } // Remove generated fields delete e1.metadata.uid; @@ -76,10 +84,6 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean { delete e2.metadata.etag; delete e2.metadata.generation; - // Remove already compared things - delete e1.metadata.annotations; - delete e2.metadata.annotations; - // Remove things that we explicitly do not compare delete e1.relations; delete e2.relations; @@ -106,14 +110,6 @@ export function generateUpdatedEntity(previous: Entity, next: Entity): Entity { const result = lodash.cloneDeep(next); - // Annotations are merged, with the new ones taking precedence - if (previous.metadata.annotations) { - next.metadata.annotations = { - ...previous.metadata.annotations, - ...next.metadata.annotations, - }; - } - // Generated fields are copied and updated const bumpEtag = entityHasChanges(previous, result); const bumpGeneration = !lodash.isEqual(previous.spec, result.spec); @@ -123,26 +119,3 @@ export function generateUpdatedEntity(previous: Entity, next: Entity): Entity { return result; } - -function entityHasAnnotationChanges(previous: Entity, next: Entity): boolean { - // Since the next annotations get merged into the previous, extract only - // the overlapping keys and check if their values match. - if (next.metadata.annotations) { - if (!previous.metadata.annotations) { - return true; - } - if ( - !lodash.isEqual( - next.metadata.annotations, - lodash.pick( - previous.metadata.annotations, - Object.keys(next.metadata.annotations), - ), - ) - ) { - return true; - } - } - - return false; -} diff --git a/packages/catalog-model/src/index.ts b/packages/catalog-model/src/index.ts index f93a4001a5..976b5f6148 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 { EntityName, EntityPolicy, EntityRef, JSONSchema } from './types'; +export type { EntityName, 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 a5d5152fab..a4d7d904cd 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts @@ -70,6 +70,7 @@ components: items: $ref: "#/components/schemas/Pet" `, + system: 'system', }, }; }); @@ -152,4 +153,19 @@ components: (entity as any).spec.definition = ''; await expect(validator.check(entity)).rejects.toThrow(/definition/); }); + + it('accepts missing system', async () => { + delete (entity as any).spec.system; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong system', async () => { + (entity as any).spec.system = 7; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); + + it('rejects empty system', async () => { + (entity as any).spec.system = ''; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); }); diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 660cd71cd8..5432cafdeb 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -14,26 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/API.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } 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; @@ -42,11 +32,13 @@ export interface ApiEntityV1alpha1 extends Entity { lifecycle: string; owner: string; definition: string; + system?: string; }; } -export const apiEntityV1alpha1Validator = schemaValidator( +export const apiEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts index 030c99c151..9284a5d5b1 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.test.ts @@ -33,8 +33,10 @@ describe('ComponentV1alpha1Validator', () => { type: 'service', lifecycle: 'production', owner: 'me', + subcomponentOf: 'monolith', providesApis: ['api-0'], consumesApis: ['api-0'], + system: 'system', }, }; }); @@ -103,6 +105,21 @@ describe('ComponentV1alpha1Validator', () => { await expect(validator.check(entity)).rejects.toThrow(/owner/); }); + it('accepts missing subcomponentOf', async () => { + delete (entity as any).spec.subcomponentOf; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong subcomponentOf', async () => { + (entity as any).spec.subcomponentOf = 7; + await expect(validator.check(entity)).rejects.toThrow(/subcomponentOf/); + }); + + it('rejects empty subcomponentOf', async () => { + (entity as any).spec.subcomponentOf = ''; + await expect(validator.check(entity)).rejects.toThrow(/subcomponentOf/); + }); + it('accepts missing providesApis', async () => { delete (entity as any).spec.providesApis; await expect(validator.check(entity)).resolves.toBe(true); @@ -142,4 +159,19 @@ describe('ComponentV1alpha1Validator', () => { (entity as any).spec.consumesApis = []; await expect(validator.check(entity)).resolves.toBe(true); }); + + it('accepts missing system', async () => { + delete (entity as any).spec.system; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong system', async () => { + (entity as any).spec.system = 7; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); + + it('rejects empty system', async () => { + (entity as any).spec.system = ''; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); }); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index e511dcb24a..3006a4288c 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -14,27 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/Component.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } 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), - providesApis: yup.array(yup.string().required()).notRequired(), - consumesApis: yup.array(yup.string().required()).notRequired(), - }) - .required(), -}); - export interface ComponentEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -42,13 +31,16 @@ export interface ComponentEntityV1alpha1 extends Entity { type: string; lifecycle: string; owner: string; + subcomponentOf?: string; providesApis?: string[]; consumesApis?: string[]; + system?: string; }; } -export const componentEntityV1alpha1Validator = schemaValidator( +export const componentEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.test.ts new file mode 100644 index 0000000000..0e989f22ca --- /dev/null +++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.test.ts @@ -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 { + DomainEntityV1alpha1, + domainEntityV1alpha1Validator as validator, +} from './DomainEntityV1alpha1'; + +describe('DomainV1alpha1Validator', () => { + let entity: DomainEntityV1alpha1; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'test', + }, + spec: { + owner: 'me', + }, + }; + }); + + it('happy path: accepts valid data', async () => { + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('silently accepts v1beta1 as well', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta1'; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('ignores unknown apiVersion', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta0'; + await expect(validator.check(entity)).resolves.toBe(false); + }); + + it('ignores unknown kind', async () => { + (entity as any).kind = 'Wizard'; + await expect(validator.check(entity)).resolves.toBe(false); + }); + + it('rejects missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('rejects wrong owner', async () => { + (entity as any).spec.owner = 7; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); +}); diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts new file mode 100644 index 0000000000..7aab35e367 --- /dev/null +++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts @@ -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 type { Entity } from '../entity/Entity'; +import schema from '../schema/kinds/Domain.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; + +const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; +const KIND = 'Domain' as const; + +export interface DomainEntityV1alpha1 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + spec: { + owner: string; + }; +} + +export const domainEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( + KIND, + API_VERSION, + schema, + [commonSchema, entityMetaSchema, entitySchema], +); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index 6f2664af77..d039fdaba8 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -14,40 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/Group.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } 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), - profile: yup - .object({ - displayName: yup.string().min(1).notRequired(), - email: yup.string().min(1).notRequired(), - picture: yup.string().min(1).notRequired(), - }) - .notRequired(), - parent: yup.string().notRequired().min(1), - // Use these manual tests because yup .required() requires at least - // one element and there is no simple workaround -_- - // the cast is there to convince typescript that the array itself is - // required without using .required() - children: yup.array(yup.string().required()).test({ - name: 'isDefined', - message: 'children must be defined', - test: v => Boolean(v), - }) as yup.ArraySchema, - }) - .required(), -}); - export interface GroupEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -63,8 +39,9 @@ export interface GroupEntityV1alpha1 extends Entity { }; } -export const groupEntityV1alpha1Validator = schemaValidator( +export const groupEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index 9cd767de94..fb452b6ac7 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -14,25 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/Location.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } 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().notRequired().min(1), - target: yup.string().notRequired().min(1), - targets: yup.array(yup.string().required()).notRequired(), - }) - .required(), -}); - export interface LocationEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -43,8 +34,9 @@ export interface LocationEntityV1alpha1 extends Entity { }; } -export const locationEntityV1alpha1Validator = schemaValidator( +export const locationEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.test.ts new file mode 100644 index 0000000000..ad8ea5cdf3 --- /dev/null +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.test.ts @@ -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 { + ResourceEntityV1alpha1, + resourceEntityV1alpha1Validator as validator, +} from './ResourceEntityV1alpha1'; + +describe('ResourceV1alpha1Validator', () => { + let entity: ResourceEntityV1alpha1; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { + name: 'test', + }, + spec: { + type: 'database', + owner: 'me', + system: 'system', + }, + }; + }); + + it('happy path: accepts valid data', async () => { + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('silently accepts v1beta1 as well', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta1'; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('ignores unknown apiVersion', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta0'; + await expect(validator.check(entity)).resolves.toBe(false); + }); + + it('ignores unknown kind', async () => { + (entity as any).kind = 'Wizard'; + await expect(validator.check(entity)).resolves.toBe(false); + }); + + it('rejects missing type', async () => { + delete (entity as any).spec.type; + await expect(validator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects wrong type', async () => { + (entity as any).spec.type = 7; + await expect(validator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects empty type', async () => { + (entity as any).spec.type = ''; + await expect(validator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('rejects wrong owner', async () => { + (entity as any).spec.owner = 7; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('accepts missing system', async () => { + delete (entity as any).spec.system; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong system', async () => { + (entity as any).spec.system = 7; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); + + it('rejects empty system', async () => { + (entity as any).spec.system = ''; + await expect(validator.check(entity)).rejects.toThrow(/system/); + }); +}); diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts new file mode 100644 index 0000000000..520a39f02c --- /dev/null +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts @@ -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 type { Entity } from '../entity/Entity'; +import schema from '../schema/kinds/Resource.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; + +const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; +const KIND = 'Resource' as const; + +export interface ResourceEntityV1alpha1 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + spec: { + type: string; + owner: string; + system?: string; + }; +} + +export const resourceEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( + KIND, + API_VERSION, + schema, + [commonSchema, entityMetaSchema, entitySchema], +); diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.test.ts new file mode 100644 index 0000000000..7d744b7d0d --- /dev/null +++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + SystemEntityV1alpha1, + systemEntityV1alpha1Validator as validator, +} from './SystemEntityV1alpha1'; + +describe('SystemV1alpha1Validator', () => { + let entity: SystemEntityV1alpha1; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'test', + }, + spec: { + owner: 'me', + domain: 'domain', + }, + }; + }); + + it('happy path: accepts valid data', async () => { + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('silently accepts v1beta1 as well', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta1'; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('ignores unknown apiVersion', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta0'; + await expect(validator.check(entity)).resolves.toBe(false); + }); + + it('ignores unknown kind', async () => { + (entity as any).kind = 'Wizard'; + await expect(validator.check(entity)).resolves.toBe(false); + }); + + it('rejects missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('rejects wrong owner', async () => { + (entity as any).spec.owner = 7; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + + it('accepts missing domain', async () => { + delete (entity as any).spec.domain; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects wrong domain', async () => { + (entity as any).spec.domain = 7; + await expect(validator.check(entity)).rejects.toThrow(/domain/); + }); + + it('rejects empty domain', async () => { + (entity as any).spec.domain = ''; + await expect(validator.check(entity)).rejects.toThrow(/domain/); + }); +}); diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts new file mode 100644 index 0000000000..1ee19466f0 --- /dev/null +++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.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 type { Entity } from '../entity/Entity'; +import schema from '../schema/kinds/System.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; + +const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; +const KIND = 'System' as const; + +export interface SystemEntityV1alpha1 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + spec: { + owner: string; + domain?: string; + }; +} + +export const systemEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( + KIND, + API_VERSION, + schema, + [commonSchema, entityMetaSchema, entitySchema], +); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index b16fcdc7e4..72479c7efa 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -14,27 +14,17 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; +import schema from '../schema/kinds/Template.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; import type { JSONSchema } from '../types'; -import { schemaValidator } from './util'; +import { ajvCompiledJsonSchemaValidator } 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; @@ -46,8 +36,9 @@ export interface TemplateEntityV1alpha1 extends Entity { }; } -export const templateEntityV1alpha1Validator = schemaValidator( +export const templateEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts index 16a5a86e05..a8700a496e 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -14,38 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/User.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } 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 -_- - // the cast is there to convince typescript that the array itself is - // required without using .required() - memberOf: yup.array(yup.string().required()).test({ - name: 'isDefined', - message: 'memberOf must be defined', - test: v => Boolean(v), - }) as yup.ArraySchema, - }) - .required(), -}); - export interface UserEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -59,8 +37,9 @@ export interface UserEntityV1alpha1 extends Entity { }; } -export const userEntityV1alpha1Validator = schemaValidator( +export const userEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 914d7efea7..bc157c79df 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +export { schemaValidator } from './util'; +export type { KindValidator } from './types'; export { apiEntityV1alpha1Validator } from './ApiEntityV1alpha1'; export type { ApiEntityV1alpha1 as ApiEntity, @@ -24,6 +26,11 @@ export type { ComponentEntityV1alpha1 as ComponentEntity, ComponentEntityV1alpha1, } from './ComponentEntityV1alpha1'; +export { domainEntityV1alpha1Validator } from './DomainEntityV1alpha1'; +export type { + DomainEntityV1alpha1 as DomainEntity, + DomainEntityV1alpha1, +} from './DomainEntityV1alpha1'; export { groupEntityV1alpha1Validator } from './GroupEntityV1alpha1'; export type { GroupEntityV1alpha1 as GroupEntity, @@ -35,12 +42,21 @@ export type { LocationEntityV1alpha1, } from './LocationEntityV1alpha1'; export * from './relations'; +export { resourceEntityV1alpha1Validator } from './ResourceEntityV1alpha1'; +export type { + ResourceEntityV1alpha1 as ResourceEntity, + ResourceEntityV1alpha1, +} from './ResourceEntityV1alpha1'; +export { systemEntityV1alpha1Validator } from './SystemEntityV1alpha1'; +export type { + SystemEntityV1alpha1 as SystemEntity, + SystemEntityV1alpha1, +} from './SystemEntityV1alpha1'; export { templateEntityV1alpha1Validator } from './TemplateEntityV1alpha1'; export type { TemplateEntityV1alpha1 as TemplateEntity, TemplateEntityV1alpha1, } from './TemplateEntityV1alpha1'; -export type { KindValidator } from './types'; export { userEntityV1alpha1Validator } from './UserEntityV1alpha1'; export type { UserEntityV1alpha1 as UserEntity, diff --git a/packages/catalog-model/src/kinds/relations.ts b/packages/catalog-model/src/kinds/relations.ts index 3d5d629b9e..8ad5017fba 100644 --- a/packages/catalog-model/src/kinds/relations.ts +++ b/packages/catalog-model/src/kinds/relations.ts @@ -30,7 +30,7 @@ export const RELATION_OWNED_BY = 'ownedBy'; export const RELATION_OWNER_OF = 'ownerOf'; /** - * A relation with an API entity, typically from a component or system + * A relation with an API entity, typically from a component */ export const RELATION_CONSUMES_API = 'consumesApi'; export const RELATION_API_CONSUMED_BY = 'apiConsumedBy'; @@ -55,3 +55,10 @@ export const RELATION_CHILD_OF = 'childOf'; */ export const RELATION_MEMBER_OF = 'memberOf'; export const RELATION_HAS_MEMBER = 'hasMember'; + +/** + * A part/whole relation, typically for components in a system and systems + * in a domain. + */ +export const RELATION_PART_OF = 'partOf'; +export const RELATION_HAS_PART = 'hasPart'; diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts index f1b02cfba1..4002f2806c 100644 --- a/packages/catalog-model/src/kinds/util.ts +++ b/packages/catalog-model/src/kinds/util.ts @@ -14,9 +14,13 @@ * limitations under the License. */ +import Ajv, { AnySchema } from 'ajv'; import * as yup from 'yup'; import { KindValidator } from './types'; +/** + * @deprecated We no longer use yup for the catalog model. This utility method will be removed. + */ export function schemaValidator( kind: string, apiVersion: readonly string[], @@ -24,10 +28,7 @@ export function schemaValidator( ): KindValidator { return { async check(envelope) { - if ( - kind !== envelope.kind || - !apiVersion.includes(envelope.apiVersion as any) - ) { + if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) { return false; } await schema.validate(envelope, { strict: true }); @@ -35,3 +36,38 @@ export function schemaValidator( }, }; } + +export function ajvCompiledJsonSchemaValidator( + kind: string, + apiVersion: readonly string[], + schema: AnySchema, + extraSchemas?: AnySchema[], +): KindValidator { + const ajv = new Ajv({ allowUnionTypes: true }); + if (extraSchemas) { + ajv.addSchema(extraSchemas, undefined, undefined, true); + } + const validate = ajv.compile(schema); + + return { + async check(envelope) { + if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) { + return false; + } + + const result = validate(envelope); + if (result === true) { + return true; + } + + const [error] = validate.errors || []; + if (!error) { + throw new TypeError(`Malformed ${kind}, Unknown error`); + } + + throw new TypeError( + `Malformed ${kind}, ${error.dataPath || ''} ${error.message}`, + ); + }, + }; +} diff --git a/packages/catalog-model/src/location/annotation.ts b/packages/catalog-model/src/location/annotation.ts index 371d095685..93f2fabea4 100644 --- a/packages/catalog-model/src/location/annotation.ts +++ b/packages/catalog-model/src/location/annotation.ts @@ -15,3 +15,5 @@ */ export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; +export const ORIGIN_LOCATION_ANNOTATION = + 'backstage.io/managed-by-origin-location'; diff --git a/packages/catalog-model/src/location/index.ts b/packages/catalog-model/src/location/index.ts index ce64b988a6..8fd516120a 100644 --- a/packages/catalog-model/src/location/index.ts +++ b/packages/catalog-model/src/location/index.ts @@ -20,4 +20,4 @@ export { locationSpecSchema, analyzeLocationSchema, } from './validation'; -export { LOCATION_ANNOTATION } from './annotation'; +export { LOCATION_ANNOTATION, ORIGIN_LOCATION_ANNOTATION } from './annotation'; diff --git a/packages/catalog-model/src/schema/Entity.schema.json b/packages/catalog-model/src/schema/Entity.schema.json new file mode 100644 index 0000000000..803e025796 --- /dev/null +++ b/packages/catalog-model/src/schema/Entity.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "Entity", + "description": "The format envelope that's common to all versions/kinds of entity.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Component", + "metadata": { + "name": "LoremService", + "description": "Creates Lorems like a pro.", + "labels": { + "product_name": "Random value Generator" + }, + "annotations": { + "docs": "https://github.com/..../tree/develop/doc" + } + }, + "spec": { + "type": "service", + "lifecycle": "production", + "owner": "tools" + } + } + ], + "type": "object", + "required": ["apiVersion", "kind", "metadata"], + "additionalProperties": false, + "properties": { + "apiVersion": { + "type": "string", + "description": "The version of specification format for this particular entity that this is written against.", + "minLength": 1, + "examples": ["backstage.io/v1alpha1", "my-company.net/v1", "1.0"] + }, + "kind": { + "type": "string", + "description": "The high level entity type being described.", + "minLength": 1, + "examples": [ + "API", + "Component", + "Domain", + "Group", + "Location", + "Resource", + "System", + "Template", + "User" + ] + }, + "metadata": { + "$ref": "EntityMeta" + }, + "spec": { + "type": "object", + "description": "The specification data describing the entity itself." + }, + "relations": { + "type": "array", + "description": "The relations that this entity has with other entities.", + "items": { + "$ref": "common#relation" + } + } + } +} diff --git a/packages/catalog-model/src/schema/EntityMeta.schema.json b/packages/catalog-model/src/schema/EntityMeta.schema.json new file mode 100644 index 0000000000..ff0f9c84a8 --- /dev/null +++ b/packages/catalog-model/src/schema/EntityMeta.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "EntityMeta", + "description": "Metadata fields common to all versions/kinds of entity.", + "examples": [ + { + "uid": "e01199ab-08cc-44c2-8e19-5c29ded82521", + "etag": "lsndfkjsndfkjnsdfkjnsd==", + "generation": 13, + "name": "my-component-yay", + "namespace": "the-namespace", + "labels": { + "backstage.io/custom": "ValueStuff" + }, + "annotations": { + "example.com/bindings": "are-secret" + }, + "tags": ["java", "data"] + } + ], + "type": "object", + "required": ["name"], + "additionalProperties": true, + "properties": { + "uid": { + "type": "string", + "description": "A globally unique ID for the entity. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, but the server is free to reject requests that do so in such a way that it breaks semantics.", + "examples": ["e01199ab-08cc-44c2-8e19-5c29ded82521"], + "minLength": 1 + }, + "etag": { + "type": "string", + "description": "An opaque string that changes for each update operation to any part of the entity, including metadata. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, and the server will then reject the operation if it does not match the current stored value.", + "examples": ["lsndfkjsndfkjnsdfkjnsd=="], + "minLength": 1 + }, + "generation": { + "type": "integer", + "description": "A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations.", + "examples": [1], + "minimum": 1 + }, + "name": { + "type": "string", + "description": "The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.", + "examples": ["metadata-proxy"], + "minLength": 1 + }, + "namespace": { + "type": "string", + "description": "The namespace that the entity belongs to.", + "default": "default", + "examples": ["default", "admin"], + "minLength": 1 + }, + "description": { + "type": "string", + "description": "A short (typically relatively few words, on one line) description of the entity." + }, + "labels": { + "type": "object", + "description": "Key/value pairs of identifying information attached to the entity.", + "additionalProperties": true, + "patternProperties": { + "^.+$": { + "type": "string" + } + } + }, + "annotations": { + "type": "object", + "description": "Key/value pairs of non-identifying auxiliary information attached to the entity.", + "additionalProperties": true, + "patternProperties": { + "^.+$": { + "type": "string" + } + } + }, + "tags": { + "type": "array", + "description": "A list of single-valued strings, to for example classify catalog entities in various ways.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "links": { + "type": "array", + "description": "A list of external hyperlinks related to the entity. Links can provide additional contextual information that may be located outside of Backstage itself. For example, an admin dashboard or external CMS page.", + "items": { + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "A url in a standard uri format.", + "examples": ["https://admin.example-org.com"], + "minLength": 1 + }, + "title": { + "type": "string", + "description": "A user friendly display name for the link.", + "examples": ["Admin Dashboard"], + "minLength": 1 + }, + "icon": { + "type": "string", + "description": "A key representing a visual icon to be displayed in the UI.", + "examples": ["dashboard"], + "minLength": 1 + } + } + } + } + } +} diff --git a/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json new file mode 100644 index 0000000000..fae1225764 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ApiV1alpha1", + "description": "An API describes an interface that can be exposed by a component. The API can be defined in different formats, like OpenAPI, AsyncAPI, GraphQL, gRPC, or other formats.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "API", + "metadata": { + "name": "artist-api", + "description": "Retrieve artist details", + "labels": { + "product_name": "Random value Generator" + }, + "annotations": { + "docs": "https://github.com/..../tree/develop/doc" + } + }, + "spec": { + "type": "openapi", + "lifecycle": "production", + "owner": "artist-relations-team", + "system": "artist-engagement-portal", + "definition": "openapi: \"3.0.0\"\ninfo:..." + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["API"] + }, + "spec": { + "type": "object", + "required": ["type", "lifecycle", "owner", "definition"], + "properties": { + "type": { + "type": "string", + "description": "The type of the API definition.", + "examples": ["openapi", "asyncapi", "graphql", "grpc"], + "minLength": 1 + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle state of the API.", + "examples": ["experimental", "production", "deprecated"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the API.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the API belongs to.", + "minLength": 1 + }, + "definition": { + "type": "string", + "description": "The definition of the API, based on the format defined by the type.", + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json new file mode 100644 index 0000000000..822059a0ea --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ComponentV1alpha1", + "description": "A Component describes a software component. It is typically intimately linked to the source code that constitutes the component, and should be what a developer may regard a \"unit of software\", usually with a distinct deployable or linkable artifact.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Component", + "metadata": { + "name": "LoremService", + "description": "Creates Lorems like a pro.", + "labels": { + "product_name": "Random value Generator" + }, + "annotations": { + "docs": "https://github.com/..../tree/develop/doc" + } + }, + "spec": { + "type": "service", + "lifecycle": "production", + "owner": "tools" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Component"] + }, + "spec": { + "type": "object", + "required": ["type", "lifecycle", "owner"], + "properties": { + "type": { + "type": "string", + "description": "The type of component.", + "examples": ["service", "website", "library"], + "minLength": 1 + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle state of the component.", + "examples": ["experimental", "production", "deprecated"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the component.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the component belongs to.", + "minLength": 1 + }, + "subcomponentOf": { + "type": "string", + "description": "An entity reference to another component of which the component is a part.", + "minLength": 1 + }, + "providesApis": { + "type": "array", + "description": "An array of entity references to the APIs that are provided by the component.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "consumesApis": { + "type": "array", + "description": "An array of entity references to the APIs that are consumed by the component.", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json new file mode 100644 index 0000000000..a1a09b1213 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "DomainV1alpha1", + "description": "A Domain groups a collection of systems that share terminology, domain models, business purpose, or documentation, i.e. form a bounded context.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Domain", + "metadata": { + "name": "artists", + "description": "Everything about artists" + }, + "spec": { + "owner": "artist-relations-team" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Domain"] + }, + "spec": { + "type": "object", + "required": ["owner"], + "properties": { + "owner": { + "type": "string", + "description": "An entity reference to the owner of the component.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json new file mode 100644 index 0000000000..fc19d98301 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "GroupV1alpha1", + "description": "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.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Group", + "metadata": { + "name": "infrastructure", + "description": "The infra business unit" + }, + "spec": { + "type": "business-unit", + "profile": { + "displayName": "Infrastructure", + "email": "infrastructure@example.com", + "picture": "https://example.com/groups/bu-infrastructure.jpeg" + }, + "parent": "ops", + "children": ["backstage", "other"] + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Group"] + }, + "spec": { + "type": "object", + "required": ["type", "children"], + "properties": { + "type": { + "type": "string", + "description": "The type of group. 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.", + "examples": ["team", "business-unit", "product-area", "root"], + "minLength": 1 + }, + "profile": { + "type": "object", + "description": "Optional profile information about the group, mainly for display purposes. All fields of this structure are also optional. The email would be a group email of some form, that the group 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 group, and that a browser could fetch and render on a group page or similar.", + "properties": { + "displayName": { + "type": "string", + "description": "A simple display name to present to users.", + "examples": ["Infrastructure"], + "minLength": 1 + }, + "email": { + "type": "string", + "description": "An email where this entity can be reached.", + "examples": ["infrastructure@example.com"], + "minLength": 1 + }, + "picture": { + "type": "string", + "description": "The URL of an image that represents this entity.", + "examples": [ + "https://example.com/groups/bu-infrastructure.jpeg" + ], + "minLength": 1 + } + } + }, + "parent": { + "type": "string", + "description": "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.", + "examples": ["ops"], + "minLength": 1 + }, + "children": { + "type": "array", + "description": "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.", + "items": { + "type": "string", + "examples": ["backstage", "other"], + "minLength": 1 + } + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json new file mode 100644 index 0000000000..d633d30229 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "LocationV1alpha1", + "description": "A location is a marker that references other places to look for catalog data.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Location", + "metadata": { + "name": "org-data" + }, + "spec": { + "type": "url", + "targets": [ + "http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml", + "http://github.com/myorg/myproject/org-data-dump/catalog-info-consultants.yaml" + ] + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Location"] + }, + "spec": { + "type": "object", + "required": [], + "properties": { + "type": { + "type": "string", + "description": "The single location type, that's common to the targets specified in the spec. If it is left out, it is inherited from the location type that originally read the entity data.", + "examples": ["url"], + "minLength": 1 + }, + "target": { + "type": "string", + "description": "A single target as a string. Can be either an absolute path/URL (depending on the type), or a relative path such as ./details/catalog-info.yaml which is resolved relative to the location of this Location entity itself.", + "examples": ["./details/catalog-info.yaml"], + "minLength": 1 + }, + "targets": { + "type": "array", + "description": "A list of targets as strings. They can all be either absolute paths/URLs (depending on the type), or relative paths such as ./details/catalog-info.yaml which are resolved relative to the location of this Location entity itself.", + "items": { + "type": "string", + "examples": [ + "./details/catalog-info.yaml", + "http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml" + ], + "minLength": 1 + } + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json new file mode 100644 index 0000000000..b426dd7f94 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ResourceV1alpha1", + "description": "A resource describes the infrastructure a system needs to operate, like BigTable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and systems allows to visualize resource footprint, and create tooling around them.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Resource", + "metadata": { + "name": "artists-db", + "description": "Stores artist details" + }, + "spec": { + "type": "database", + "owner": "artist-relations-team", + "system": "artist-engagement-portal" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Resource"] + }, + "spec": { + "type": "object", + "required": ["type", "owner"], + "properties": { + "type": { + "type": "string", + "description": "The type of resource.", + "examples": ["database", "s3-bucket", "cluster"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the resource.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the resource belongs to.", + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json new file mode 100644 index 0000000000..2cdbc37076 --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SystemV1alpha1", + "description": "A system is a collection of resources and components. The system may expose or consume one or several APIs. It is viewed as abstraction level that provides potential consumers insights into exposed features without needing a too detailed view into the details of all components. This also gives the owning team the possibility to decide about published artifacts and APIs.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "System", + "metadata": { + "name": "artist-engagement-portal", + "description": "Handy tools to keep artists in the loop" + }, + "spec": { + "owner": "artist-relations-team", + "domain": "artists" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["System"] + }, + "spec": { + "type": "object", + "required": ["owner"], + "properties": { + "owner": { + "type": "string", + "description": "An entity reference to the owner of the component.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + }, + "domain": { + "type": "string", + "description": "An entity reference to the domain that the system belongs to.", + "examples": ["artists"], + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json new file mode 100644 index 0000000000..5bb83d116b --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "TemplateV1alpha1", + "description": "A Template describes a skeleton for use with the Scaffolder. It is used for describing what templating library is supported, and also for documenting the variables that the template requires using JSON Forms Schema.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Template", + "metadata": { + "name": "react-ssr-template", + "title": "React SSR Template", + "description": "Next.js application skeleton for creating isomorphic web applications.", + "tags": ["recommended", "react"] + }, + "spec": { + "owner": "artist-relations-team", + "templater": "cookiecutter", + "type": "website", + "path": ".", + "schema": { + "required": ["component-id", "description"], + "properties": { + "component_id": { + "title": "Name", + "type": "string", + "description": "Unique name of the component" + }, + "description": { + "title": "Description", + "type": "string", + "description": "Description of the component" + } + } + } + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Template"] + }, + "metadata": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The nice display name for the template. This field is required as is used to reference the template to the user instead of the metadata.name field.", + "examples": ["React SSR Template"], + "minLength": 1 + } + } + }, + "spec": { + "type": "object", + "required": ["type", "templater", "schema"], + "properties": { + "type": { + "type": "string", + "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", + "examples": ["service", "website", "library"], + "minLength": 1 + }, + "templater": { + "type": "string", + "description": "The templating library that is supported by the template skeleton.", + "examples": ["cookiecutter"], + "minLength": 1 + }, + "path": { + "type": "string", + "description": "The string location where the templater should be run if it is not on the same level as the template.yaml definition.", + "examples": ["./cookiecutter/skeleton"], + "minLength": 1 + }, + "schema": { + "type": "object", + "description": "The JSONSchema describing the inputs for the template." + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json new file mode 100644 index 0000000000..a71409d5ba --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "UserV1alpha1", + "description": "A user describes a person, such as an employee, a contractor, or similar. Users belong to Group entities in the catalog. These catalog user entries are connected to the way that authentication within the Backstage ecosystem works. See the auth section of the docs for a discussion of these concepts.", + "examples": [ + { + "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"] + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["User"] + }, + "spec": { + "type": "object", + "required": ["memberOf"], + "properties": { + "profile": { + "type": "object", + "description": "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.", + "properties": { + "displayName": { + "type": "string", + "description": "A simple display name to present to users.", + "examples": ["Jenny Doe"], + "minLength": 1 + }, + "email": { + "type": "string", + "description": "An email where this user can be reached.", + "examples": ["jenny-doe@example.com"], + "minLength": 1 + }, + "picture": { + "type": "string", + "description": "The URL of an image that represents this user.", + "examples": [ + "https://example.com/staff/jenny-with-party-hat.jpeg" + ], + "minLength": 1 + } + } + }, + "memberOf": { + "type": "array", + "description": "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.", + "items": { + "type": "string", + "examples": ["team-b", "employees"], + "minLength": 1 + } + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/src/schema/shared/common.schema.json b/packages/catalog-model/src/schema/shared/common.schema.json new file mode 100644 index 0000000000..84ae8a4147 --- /dev/null +++ b/packages/catalog-model/src/schema/shared/common.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "common", + "type": "object", + "description": "Common definitions to import from other schemas", + "definitions": { + "reference": { + "$id": "#reference", + "type": "object", + "description": "A reference by name to another entity.", + "required": ["kind", "namespace", "name"], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "description": "The kind field of the entity." + }, + "namespace": { + "type": "string", + "description": "The metadata.namespace field of the entity." + }, + "name": { + "type": "string", + "description": "The metadata.name field of the entity." + } + } + }, + "relation": { + "$id": "#relation", + "type": "object", + "description": "A directed relation from one entity to another.", + "required": ["type", "source", "target"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "minLength": 1, + "pattern": "^\\w+$", + "description": "The type of relation." + }, + "source": { + "$ref": "#reference" + }, + "target": { + "$ref": "#reference" + } + } + } + } +} diff --git a/packages/catalog-model/src/types.ts b/packages/catalog-model/src/types.ts index aa96e46102..edac03466d 100644 --- a/packages/catalog-model/src/types.ts +++ b/packages/catalog-model/src/types.ts @@ -16,23 +16,6 @@ import { JsonValue } from '@backstage/config'; import { JSONSchema7 } from 'json-schema'; -import type { Entity } from './entity/Entity'; - -/** - * A policy for validation or mutation to be applied to entities as they are - * entering the system. - */ -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, or - * undefined if this processor could not handle the entity - * @throws An error if the entity should be rejected - */ - enforce(entity: Entity): Promise; -}; export type JSONSchema = JSONSchema7 & { [key in string]?: JsonValue }; diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts index 129fdd2b62..bef997d54a 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts @@ -161,4 +161,44 @@ describe('CommonValidatorFunctions', () => { ])(`isValidDnsLabel %p ? %p`, (value, result) => { expect(CommonValidatorFunctions.isValidDnsLabel(value)).toBe(result); }); + + it.each([ + [null, false], + [7, false], + ['', false], + ['abc', false], + [' abc', false], + ['', false], + [{}, false], + ['http://foo', true], + ['https://www.foo.com/', true], + ['https://foo.com:8080', true], + ['https://foo:8080/page', true], + ['https://foo:8080/sub/page', true], + ['https://foo:8080/sub/page?query', true], + ['https://foo:8080/sub/page/?query=value', true], + ['https://foo:8080/sub/page/?query=value&', true], + ['https://foo:8080/sub/page/?query=value&another=val', true], + ['https://foo.com/page#fragment', true], + ['ftp://ftp.some.domain.com/path', true], + ['xyz://custom-protocol:4444/path', true], + ])(`isValidUrl %p ? %p`, (value, result) => { + expect(CommonValidatorFunctions.isValidUrl(value)).toBe(result); + }); + + it.each([ + [null, false], + [true, false], + [7, false], + [{}, false], + ['', false], + [' ', false], + [' ', false], + ['abc', true], + [' abc ', true], + ['abc xyz', true], + ['abc xyz abc.', true], + ])(`isValidString %p ? %p`, (value, result) => { + expect(CommonValidatorFunctions.isValidString(value)).toBe(result); + }); }); diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts index 90b4e1a611..87b6ad3838 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts @@ -92,4 +92,32 @@ export class CommonValidatorFunctions { /^[a-z0-9]+(\-[a-z0-9]+)*$/.test(value) ); } + + /** + * Checks that the value is a valid URL. + * + * @param value The value to check + */ + static isValidUrl(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + try { + // eslint-disable-next-line no-new + new URL(value); + return true; + } catch { + return false; + } + } + + /** + * Checks that the value is a non empty string value. + * + * @param value The value to check + */ + static isValidString(value: unknown): boolean { + return typeof value === 'string' && value?.trim()?.length >= 1; + } } diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d88220cc23..c40f7605eb 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,100 @@ # @backstage/cli +## 0.6.0 + +### Minor Changes + +- 19fe61c27: We have updated the default `eslint` rules in the `@backstage/cli` package. + + ```diff + -'@typescript-eslint/no-shadow': 'off', + -'@typescript-eslint/no-redeclare': 'off', + +'no-shadow': 'off', + +'no-redeclare': 'off', + +'@typescript-eslint/no-shadow': 'error', + +'@typescript-eslint/no-redeclare': 'error', + ``` + + The rules are documented [here](https://eslint.org/docs/rules/no-shadow) and [here](https://eslint.org/docs/rules/no-redeclare). + + This involved a large number of small changes to the code base. When you compile your own code using the CLI, you may also be + affected. We consider these rules important, and the primary recommendation is to try to update your code according to the + documentation above. But those that prefer to not enable the rules, or need time to perform the updates, may update their + local `.eslintrc.js` file(s) in the repo root and/or in individual plugins as they see fit: + + ```js + module.exports = { + // ... other declarations + rules: { + '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-redeclare': 'off', + }, + }; + ``` + + Because of the nature of this change, we're unable to provide a grace period for the update :( + +### Patch Changes + +- 398e1f83e: Update `create-plugin` template to use the new composability API, by switching to exporting a single routable extension component. +- e9aab60c7: Fixed module resolution of external libraries during backend development. Modules used to be resolved relative to the backend entrypoint, but are now resolved relative to each individual module. +- a08c4b0b0: Add check for outdated/duplicate packages to yarn start +- Updated dependencies [062df71db] +- Updated dependencies [e9aab60c7] + - @backstage/config-loader@0.5.1 + +## 0.5.0 + +### Minor Changes + +- 12a56cdfe: We've bumped the `@eslint-typescript` packages to the latest, which now add some additional rules that might cause lint failures. + The main one which could become an issue is the [no-use-before-define](https://eslint.org/docs/rules/no-use-before-define) rule. + + Every plugin and app has the ability to override these rules if you want to ignore them for now. + + You can reset back to the default behaviour by using the following in your own `.eslint.js` + + ```js + rules: { + 'no-use-before-define': 'off' + } + ``` + + Because of the nature of this change, we're unable to provide a grace period for the update :( + +### Patch Changes + +- ef7957be4: Add `--lax` option to `config:print` and `config:check`, which causes all environment variables to be assumed to be set. +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] + - @backstage/config-loader@0.5.0 + +## 0.4.7 + +### Patch Changes + +- b604a9d41: Append `-credentials.yaml` to credentials file generated by `backstage-cli create-github-app` and display warning about sensitive contents. + +## 0.4.6 + +### Patch Changes + +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- 08e9893d2: Handle no npm info +- 9cf71f8bf: Added experimental `create-github-app` command. + +## 0.4.5 + +### Patch Changes + +- 37a7d26c4: Use consistent file extensions for JS output when building packages. +- 818d45e94: Fix detection of external package child directories +- 0588be01f: Add `backend:bundle` command for bundling a backend package with dependencies into a deployment archive. +- b8abdda57: Add color to output from `versions:bump` in order to make it easier to spot changes. Also highlight possible breaking changes and link to changelogs. +- Updated dependencies [ad5c56fd9] + - @backstage/config-loader@0.4.1 + ## 0.4.4 ### Patch Changes @@ -10,7 +105,7 @@ ### Patch Changes -- 19554f6d6: Added Github Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` +- 19554f6d6: Added GitHub Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` - 7d72f9b09: Fix for `app.listen.host` configuration not properly overriding listening host. ## 0.4.2 diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index 67f0a49b9f..e7fd4cb688 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -34,12 +34,14 @@ module.exports = { parserOptions: { ecmaVersion: 2018, sourceType: 'module', + lib: require('./tsconfig.json').compilerOptions.lib, }, ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { - // TODO(Rugvip): We need to bump @typescript-eslint to v4 to enable these - '@typescript-eslint/no-shadow': 0, - '@typescript-eslint/no-redeclare': 0, + 'no-shadow': 'off', + 'no-redeclare': 'off', + '@typescript-eslint/no-shadow': 'error', + '@typescript-eslint/no-redeclare': 'error', 'no-console': 0, // Permitted in console programs 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()' @@ -79,6 +81,13 @@ module.exports = { ], }, overrides: [ + { + files: ['**/*.ts?(x)'], + rules: { + '@typescript-eslint/no-unused-vars': 'off', + 'no-undef': 'off', + }, + }, { files: ['*.test.*', 'src/setupTests.*', 'dev/**'], rules: { diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index e2a807e42e..37bd7bed94 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -32,7 +32,11 @@ module.exports = { }, parserOptions: { ecmaVersion: 2018, + ecmaFeatures: { + jsx: true, + }, sourceType: 'module', + lib: require('./tsconfig.json').compilerOptions.lib, }, settings: { react: { @@ -41,10 +45,11 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { - // TODO(Rugvip): We need to bump @typescript-eslint to v4 to enable these - '@typescript-eslint/no-shadow': 0, - '@typescript-eslint/no-redeclare': 0, - + 'no-shadow': 'off', + 'no-redeclare': 'off', + '@typescript-eslint/no-shadow': 'error', + '@typescript-eslint/no-redeclare': 'error', + 'no-undef': 'off', 'import/newline-after-import': 'error', 'import/no-duplicates': 'warn', 'import/no-extraneous-dependencies': [ @@ -90,6 +95,8 @@ module.exports = { rules: { // Default to not enforcing prop-types in typescript 'react/prop-types': 0, + '@typescript-eslint/no-unused-vars': 'off', + 'no-undef': 'off', }, }, { diff --git a/packages/cli/package.json b/packages/cli/package.json index 6da871e74c..9aaeb00459 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.4.4", + "version": "0.6.0", "private": false, "publishConfig": { "access": "public" @@ -30,11 +30,12 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", - "@backstage/config-loader": "^0.4.0", + "@backstage/config-loader": "^0.5.1", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", - "@rollup/plugin-commonjs": "^16.0.0", + "@octokit/request": "^5.4.12", + "@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-yaml": "^2.1.1", @@ -49,8 +50,8 @@ "@types/start-server-webpack-plugin": "^2.2.0", "@types/webpack-env": "^1.15.2", "@types/webpack-node-externals": "^2.5.0", - "@typescript-eslint/eslint-plugin": "^v3.10.1", - "@typescript-eslint/parser": "^v3.10.1", + "@typescript-eslint/eslint-plugin": "^v4.14.0", + "@typescript-eslint/parser": "^v4.14.0", "@yarnpkg/lockfile": "^1.1.0", "bfj": "^7.0.2", "chalk": "^4.0.0", @@ -69,6 +70,7 @@ "eslint-plugin-monorepo": "^0.3.2", "eslint-plugin-react": "^7.12.4", "eslint-plugin-react-hooks": "^4.0.0", + "express": "^4.17.1", "fork-ts-checker-webpack-plugin": "^4.0.5", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", @@ -91,7 +93,7 @@ "rollup-plugin-esbuild": "2.6.x", "rollup-plugin-peer-deps-external": "^2.2.2", "rollup-plugin-postcss": "^3.1.1", - "rollup-plugin-typescript2": "^0.27.3", + "rollup-plugin-typescript2": "^0.29.0", "rollup-pluginutils": "^2.8.2", "semver": "^7.3.2", "start-server-webpack-plugin": "^2.2.5", @@ -111,21 +113,21 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.4.1", + "@backstage/backend-common": "^0.5.2", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.3", - "@backstage/dev-utils": "^0.1.7", + "@backstage/core": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", - "@backstage/theme": "^0.2.2", + "@backstage/theme": "^0.2.3", "@types/diff": "^4.0.2", + "@types/express": "^4.17.6", "@types/fs-extra": "^9.0.1", "@types/html-webpack-plugin": "^3.2.2", "@types/http-proxy": "^1.17.4", "@types/inquirer": "^7.3.1", - "@types/mini-css-extract-plugin": "^0.9.1", + "@types/mini-css-extract-plugin": "^1.2.2", "@types/mock-fs": "^4.13.0", "@types/node": "^13.7.2", - "@types/ora": "^3.2.0", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", "@types/rollup-plugin-peer-deps-external": "^2.2.0", @@ -171,7 +173,9 @@ "type": "string", "visibility": "frontend", "description": "Tracking ID for Google Analytics", - "example": "UA-000000-0" + "examples": [ + "UA-000000-0" + ] }, "listen": { "type": "object", diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts index dc165ecb90..b542030109 100644 --- a/packages/cli/src/commands/app/serve.ts +++ b/packages/cli/src/commands/app/serve.ts @@ -15,12 +15,43 @@ */ import fs from 'fs-extra'; +import chalk from 'chalk'; +import uniq from 'lodash/uniq'; import { Command } from 'commander'; import { serveBundle } from '../../lib/bundler'; import { loadCliConfig } from '../../lib/config'; import { paths } from '../../lib/paths'; +import { Lockfile } from '../../lib/versioning'; +import { includedFilter } from '../versions/lint'; export default async (cmd: Command) => { + const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + const result = lockfile.analyze({ + filter: includedFilter, + }); + const problemPackages = [...result.newVersions, ...result.newRanges].map( + ({ name }) => name, + ); + + if (problemPackages.length > 1) { + console.log( + chalk.yellow( + `⚠️ Some of the following packages may be outdated or have duplicate installations: + + ${uniq(problemPackages).join(', ')} + `, + ), + ); + console.log( + chalk.yellow( + `⚠️ This can be resolved using the following command: + + yarn backstage-cli versions:check --fix + `, + ), + ); + } + const { name } = await fs.readJson(paths.resolveTarget('package.json')); const waitForExit = await serveBundle({ entry: 'src/index', diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts index 8be88adce7..930f2c98ca 100644 --- a/packages/cli/src/commands/config/print.ts +++ b/packages/cli/src/commands/config/print.ts @@ -24,6 +24,7 @@ export default async (cmd: Command) => { const { schema, appConfigs } = await loadCliConfig({ args: cmd.config, fromPackage: cmd.package, + mockEnv: cmd.lax, }); const visibility = getVisibilityOption(cmd); const data = serializeConfigData(appConfigs, schema, visibility); @@ -62,8 +63,8 @@ function serializeConfigData( } const sanitizedConfigs = schema.process(appConfigs, { - valueTransform: (value, { visibility }) => - visibility === 'secret' ? '' : value, + valueTransform: (value, context) => + context.visibility === 'secret' ? '' : value, }); return ConfigReader.fromConfigs(sanitizedConfigs).get(); diff --git a/packages/cli/src/commands/config/validate.ts b/packages/cli/src/commands/config/validate.ts index 581e4bae43..37f41164af 100644 --- a/packages/cli/src/commands/config/validate.ts +++ b/packages/cli/src/commands/config/validate.ts @@ -21,5 +21,6 @@ export default async (cmd: Command) => { await loadCliConfig({ args: cmd.config, fromPackage: cmd.package, + mockEnv: cmd.lax, }); }; diff --git a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts new file mode 100644 index 0000000000..45671c2ead --- /dev/null +++ b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts @@ -0,0 +1,148 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import crypto from 'crypto'; +import openBrowser from 'react-dev-utils/openBrowser'; +import { request } from '@octokit/request'; +import express, { Express, Request, Response } from 'express'; + +const MANIFEST_DATA = { + default_events: ['create', 'delete', 'push', 'repository'], + default_permissions: { + contents: 'read', + metadata: 'read', + }, + name: 'Backstage-', + url: 'https://backstage.io', + description: 'GitHub App for Backstage', + public: false, +}; + +const FORM_PAGE = ` + + +
+ + +
+ + + +`; + +type GithubAppConfig = { + appId: number; + slug?: string; + name?: string; + webhookUrl?: string; + clientId: string; + clientSecret: string; + webhookSecret: string; + privateKey: string; +}; + +export class GithubCreateAppServer { + private baseUrl?: string; + private webhookUrl?: string; + + static async run({ org }: { org: string }): Promise { + const encodedOrg = encodeURIComponent(org); + const actionUrl = `https://github.com/organizations/${encodedOrg}/settings/apps/new`; + const server = new GithubCreateAppServer(actionUrl); + return server.start(); + } + + constructor(private readonly actionUrl: string) { + const webhookId = crypto + .randomBytes(15) + .toString('base64') + .replace(/[\+\/]/g, ''); + + this.webhookUrl = `https://smee.io/${webhookId}`; + } + + private async start(): Promise { + const app = express(); + + app.get('/', this.formHandler); + + const callPromise = new Promise((resolve, reject) => { + app.get('/callback', (req, res) => { + request( + `POST /app-manifests/${encodeURIComponent( + req.query.code as string, + )}/conversions`, + ).then(({ data }) => { + resolve({ + name: data.name, + slug: data.slug, + appId: data.id, + webhookUrl: this.webhookUrl, + clientId: data.client_id, + clientSecret: data.client_secret, + webhookSecret: data.webhook_secret, + privateKey: data.pem, + }); + res.redirect(302, `${data.html_url}/installations/new`); + }, reject); + }); + }); + + this.baseUrl = await this.listen(app); + + openBrowser(this.baseUrl); + + return callPromise; + } + + private formHandler = (_req: Request, res: Response) => { + const baseUrl = this.baseUrl; + if (!baseUrl) { + throw new Error('baseUrl is not set'); + } + const manifest = { + ...MANIFEST_DATA, + redirect_url: `${baseUrl}/callback`, + hook_attributes: { + url: this.webhookUrl, + }, + }; + const manifestJson = JSON.stringify(manifest).replace(/\"/g, '"'); + + let body = FORM_PAGE; + body = body.replace('MANIFEST_JSON', manifestJson); + body = body.replace('ACTION_URL', this.actionUrl); + + res.setHeader('content-type', 'text/html'); + res.send(body); + }; + + private async listen(app: Express) { + return new Promise((resolve, reject) => { + const listener = app.listen(0, () => { + const info = listener.address(); + if (typeof info !== 'object' || info === null) { + reject(new Error(`Unexpected listener info '${info}'`)); + return; + } + const { port } = info; + resolve(`http://localhost:${port}`); + }); + }); + } +} diff --git a/packages/cli/src/commands/create-github-app/index.ts b/packages/cli/src/commands/create-github-app/index.ts new file mode 100644 index 0000000000..cd9e8dbe09 --- /dev/null +++ b/packages/cli/src/commands/create-github-app/index.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 fs from 'fs-extra'; +import chalk from 'chalk'; +import { stringify as stringifyYaml } from 'yaml'; +import { paths } from '../../lib/paths'; +import { GithubCreateAppServer } from './GithubCreateAppServer'; + +// This is an experimental command that at this point does not support GitHub Enterprise +// due to lacking support for creating apps from manifests. +// https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app-from-a-manifest +export default async (org: string) => { + const { slug, name, ...config } = await GithubCreateAppServer.run({ org }); + + const fileName = `github-app-${slug}-credentials.yaml`; + const content = `# Name: ${name}\n${stringifyYaml(config)}`; + await fs.writeFile(paths.resolveTargetRoot(fileName), content); + console.log(`GitHub App configuration written to ${chalk.cyan(fileName)}`); + console.log( + chalk.yellow( + 'This file contains sensitive credentials, it should not be committed to version control and handled with care!', + ), + ); + // TODO: log instructions on how to use the newly created app configuration. +}; diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index a2005c5580..affbf64922 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -20,6 +20,8 @@ import chalk from 'chalk'; import inquirer, { Answers, Question } from 'inquirer'; import { exec as execCb } from 'child_process'; import { resolve as resolvePath, join as joinPath } from 'path'; +import camelCase from 'lodash/camelCase'; +import upperFirst from 'lodash/upperFirst'; import os from 'os'; import { Command } from 'commander'; import { @@ -104,16 +106,12 @@ export async function addPluginDependencyToApp( }); } -export async function addPluginToApp( +export async function addPluginImportToApp( rootDir: string, - pluginName: string, + pluginVar: string, pluginPackage: string, ) { - const pluginNameCapitalized = pluginName - .split('-') - .map(name => capitalize(name)) - .join(''); - const pluginExport = `export { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`; + const pluginExport = `export { ${pluginVar} } from '${pluginPackage}';`; const pluginsFilePath = 'packages/app/src/plugins.ts'; const pluginsFile = resolvePath(rootDir, pluginsFilePath); @@ -126,6 +124,46 @@ export async function addPluginToApp( }); } +export async function addPluginExtensionToApp( + pluginId: string, + extensionName: string, + pluginPackage: string, +) { + const pluginsFilePath = paths.resolveTargetRoot('packages/app/src/App.tsx'); + if (!(await fs.pathExists(pluginsFilePath))) { + return; + } + + await Task.forItem('processing', pluginsFilePath, async () => { + const content = await fs.readFile(pluginsFilePath, 'utf8'); + const revLines = content.split('\n').reverse(); + + const lastImportIndex = revLines.findIndex(line => + line.match(/ from ("|').*("|')/), + ); + const lastRouteIndex = revLines.findIndex(line => + line.match(/<\/FlatRoutes/), + ); + + if (lastImportIndex !== -1 && lastRouteIndex !== -1) { + revLines.splice( + lastImportIndex, + 0, + `import { ${extensionName} } from '${pluginPackage}';`, + ); + const [indentation] = revLines[lastRouteIndex + 1].match(/^\s*/) ?? []; + revLines.splice( + lastRouteIndex + 1, + 0, + `${indentation}}/>`, + ); + + const newContent = revLines.reverse().join('\n'); + await fs.writeFile(pluginsFilePath, newContent, 'utf8'); + } + }); +} + async function cleanUp(tempDir: string) { await Task.forItem('remove', 'temporary directory', async () => { await fs.remove(tempDir); @@ -223,6 +261,8 @@ export default async (cmd: Command) => { const name = cmd.scope ? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}` : `plugin-${pluginId}`; + const pluginVar = `${camelCase(answers.id)}Plugin`; + const extensionName = `${upperFirst(camelCase(answers.id))}Page`; const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : ''; const privatePackage = cmd.private === false ? false : true; const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json')); @@ -259,7 +299,9 @@ export default async (cmd: Command) => { tempDir, { ...answers, + pluginVar, pluginVersion, + extensionName, name, privatePackage, npmRegistry, @@ -278,7 +320,8 @@ export default async (cmd: Command) => { await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion); Task.section('Import plugin in app'); - await addPluginToApp(paths.targetRoot, pluginId, name); + await addPluginImportToApp(paths.targetRoot, pluginVar, name); + await addPluginExtensionToApp(pluginId, extensionName, name); } if (ownerIds && ownerIds.length) { diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index ac8e214d26..1d267c6889 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -153,6 +153,7 @@ export function registerCommands(program: CommanderStatic) { '--package ', 'Only load config schema that applies to the given package', ) + .option('--lax', 'Do not require environment variables to be set') .option('--frontend', 'Print only the frontend configuration') .option('--with-secrets', 'Include secrets in the printed configuration') .option( @@ -169,6 +170,7 @@ export function registerCommands(program: CommanderStatic) { '--package ', 'Only load config schema that applies to the given package', ) + .option('--lax', 'Do not require environment variables to be set') .option(...configOption) .description( 'Validate that the given configuration loads and matches schema', @@ -205,6 +207,13 @@ export function registerCommands(program: CommanderStatic) { .command('build-workspace ...') .description('Builds a temporary dist workspace from the provided packages') .action(lazy(() => import('./buildWorkspace').then(m => m.default))); + + program + .command('create-github-app ', { hidden: true }) + .description( + 'Create new GitHub App in your organization. This command is experimental and may change in the future.', + ) + .action(lazy(() => import('./create-github-app').then(m => m.default))); } // Wraps an action function so that it always exits and handles errors @@ -215,6 +224,7 @@ function lazy( try { const actionFunc = await getActionFunc(); await actionFunc(...args); + process.exit(0); } catch (error) { exitWithError(error); diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts index 416d67869b..fb96250567 100644 --- a/packages/cli/src/commands/plugin/diff.ts +++ b/packages/cli/src/commands/plugin/diff.ts @@ -39,6 +39,11 @@ const fileHandlers = [ patterns: ['package.json'], handler: handlers.packageJson, }, + { + // Not all plugins have routes + patterns: ['src/routes.ts'], + handler: handlers.skip, + }, { // make sure files in 1st level of src/ and dev/ exist patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/], diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 1325f0e2fd..31803d4827 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -23,6 +23,15 @@ import * as runObj from '../../lib/run'; import bump from './bump'; import { withLogCollector } from '@backstage/test-utils'; +// Remove log coloring to simplify log matching +jest.mock('chalk', () => ({ + blue: (str: string) => str, + cyan: (str: string) => str, + green: (str: string) => str, + magenta: (str: string) => str, + yellow: (str: string) => str, +})); + const REGISTRY_VERSIONS: { [name: string]: string } = { '@backstage/core': '1.0.6', '@backstage/core-api': '1.0.7', @@ -99,7 +108,7 @@ describe('bump', () => { paths.targetDir = '/'; jest .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...paths) => resolvePath('/', ...paths)); + .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => JSON.stringify({ type: 'inspect', @@ -121,11 +130,15 @@ describe('bump', () => { 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/core-api', 'Some packages are outdated, updating', - 'Removing lockfile entry for @backstage/core@^1.0.3 to bump to 1.0.6', - 'Removing lockfile entry for @backstage/core-api@^1.0.6 to bump to 1.0.7', - 'Removing lockfile entry for @backstage/core-api@^1.0.3 to bump to 1.0.7', - 'Bumping @backstage/theme in b to ^2.0.0', - "Running 'yarn install' to install new versions", + 'unlocking @backstage/core@^1.0.3 ~> 1.0.6', + 'unlocking @backstage/core-api@^1.0.6 ~> 1.0.7', + 'unlocking @backstage/core-api@^1.0.3 ~> 1.0.7', + 'bumping @backstage/theme in b to ^2.0.0', + 'Running yarn install to install new versions', + '⚠️ The following packages may have breaking changes:', + ' @backstage/theme', + ' https://github.com/backstage/backstage/blob/master/packages/theme/CHANGELOG.md', + 'Version bump complete!', ]); expect(runObj.runPlain).toHaveBeenCalledTimes(3); @@ -164,4 +177,71 @@ describe('bump', () => { }, }); }); + + it('should ignore not found packages', async () => { + // Make sure all modules involved in package discovery are in the module cache before we mock fs + await mapDependencies(paths.targetDir); + mockFs({ + '/yarn.lock': lockfileMockResult, + '/lerna.json': JSON.stringify({ + packages: ['packages/*'], + }), + '/packages/a/package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), + '/packages/b/package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^2.0.0', + }, + }), + }); + + paths.targetDir = '/'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...path) => resolvePath('/', ...path)); + jest.spyOn(runObj, 'runPlain').mockImplementation(async () => ''); + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + const { log: logs } = await withLogCollector(['log'], async () => { + await bump(); + }); + expect(logs.filter(Boolean)).toEqual([ + 'Checking for updates of @backstage/theme', + 'Checking for updates of @backstage/core', + 'Package info not found, ignoring package @backstage/theme', + 'Package info not found, ignoring package @backstage/core', + 'Checking for updates of @backstage/theme', + 'Checking for updates of @backstage/core', + 'Package info not found, ignoring package @backstage/theme', + 'Package info not found, ignoring package @backstage/core', + 'All Backstage packages are up to date!', + ]); + + expect(runObj.run).toHaveBeenCalledTimes(0); + + const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + expect(lockfileContents).toBe(lockfileMockResult); + + const packageA = await fs.readJson('/packages/a/package.json'); + expect(packageA).toEqual({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', // not bumped + }, + }); + const packageB = await fs.readJson('/packages/b/package.json'); + expect(packageB).toEqual({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', // not bumped + '@backstage/theme': '^2.0.0', // not bumped + }, + }); + }); }); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 441f65f353..ceb2f7f988 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -15,6 +15,7 @@ */ import fs from 'fs-extra'; +import chalk from 'chalk'; import semver from 'semver'; import { resolve as resolvePath } from 'path'; import { run } from '../../lib/run'; @@ -35,6 +36,7 @@ const DEP_TYPES = [ type PkgVersionInfo = { range: string; + target: string; name: string; location: string; }; @@ -53,7 +55,16 @@ export default async () => { // Track package versions that we want to remove from yarn.lock in order to trigger a bump const unlocked = Array<{ name: string; range: string; target: string }>(); await workerThreads(16, dependencyMap.entries(), async ([name, pkgs]) => { - const target = await findTargetVersion(name); + let target: string; + try { + target = await findTargetVersion(name); + } catch (error) { + if (error.name === 'NotFoundError') { + console.log(`Package info not found, ignoring package ${name}`); + return; + } + throw error; + } for (const pkg of pkgs) { if (semver.satisfies(target, pkg.range)) { @@ -69,6 +80,7 @@ export default async () => { name, location: pkg.location, range: `^${target}`, // TODO(Rugvip): Option to use something else than ^? + target, }), ); } @@ -81,7 +93,16 @@ export default async () => { return; } - const target = await findTargetVersion(name); + let target: string; + try { + target = await findTargetVersion(name); + } catch (error) { + if (error.name === 'NotFoundError') { + console.log(`Package info not found, ignoring package ${name}`); + return; + } + throw error; + } for (const entry of lockfile.get(name) ?? []) { // Ignore lockfile entries that don't satisfy the version range, since @@ -98,9 +119,9 @@ export default async () => { // Write all discovered version bumps to package.json in this repo if (versionBumps.size === 0 && unlocked.length === 0) { - console.log('All Backstage packages are up to date!'); + console.log(chalk.green('All Backstage packages are up to date!')); } else { - console.log('Some packages are outdated, updating'); + console.log(chalk.yellow('Some packages are outdated, updating')); console.log(); if (unlocked.length > 0) { @@ -115,7 +136,9 @@ export default async () => { if (!removed.has(key)) { removed.add(key); console.log( - `Removing lockfile entry for ${name}@${range} to bump to ${target}`, + `${chalk.magenta('unlocking')} ${name}@${chalk.yellow( + range, + )} ~> ${chalk.yellow(target)}`, ); lockfile.remove(name, range); } @@ -123,16 +146,34 @@ export default async () => { await lockfile.save(); } + const breakingUpdates = new Map(); await workerThreads(16, versionBumps.entries(), async ([name, deps]) => { const pkgPath = resolvePath(deps[0].location, 'package.json'); const pkgJson = await fs.readJson(pkgPath); for (const dep of deps) { - console.log(`Bumping ${dep.name} in ${name} to ${dep.range}`); + console.log( + `${chalk.cyan('bumping')} ${dep.name} in ${chalk.cyan( + name, + )} to ${chalk.yellow(dep.range)}`, + ); for (const depType of DEP_TYPES) { if (depType in pkgJson && dep.name in pkgJson[depType]) { + const oldRange = pkgJson[depType][dep.name]; pkgJson[depType][dep.name] = dep.range; + + // Check if the update was at least a pre-v1 minor or post-v1 major release + const lockfileEntry = lockfile + .get(dep.name) + ?.find(entry => entry.range === oldRange); + if (lockfileEntry) { + const from = lockfileEntry.version; + const to = dep.target; + if (!semver.satisfies(to, `^${from}`)) { + breakingUpdates.set(dep.name, { from, to }); + } + } } } } @@ -141,9 +182,42 @@ export default async () => { }); console.log(); - console.log("Running 'yarn install' to install new versions"); + console.log( + `Running ${chalk.blue('yarn install')} to install new versions`, + ); console.log(); await run('yarn', ['install']); + + if (breakingUpdates.size > 0) { + console.log(); + console.log( + chalk.yellow('⚠️ The following packages may have breaking changes:'), + ); + console.log(); + + for (const name of Array.from(breakingUpdates.keys()).sort()) { + console.log(` ${chalk.yellow(name)}`); + + let path; + if (name.startsWith('@backstage/plugin-')) { + path = `plugins/${name.replace('@backstage/plugin-', '')}`; + } else if (name.startsWith('@backstage/')) { + path = `packages/${name.replace('@backstage/', '')}`; + } + if (path) { + // TODO(Rugvip): Grab these URLs and paths from package.json, possibly verify existence + // Possibly invent new "changelog" field in package.json or some sh*t. + console.log( + ` https://github.com/backstage/backstage/blob/master/${path}/CHANGELOG.md`, + ); + } + console.log(); + } + } else { + console.log(); + } + + console.log(chalk.green('Version bump complete!')); } console.log(); diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts index b4580ea281..f3906bfd0d 100644 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts @@ -84,6 +84,23 @@ describe('LinkedPackageResolvePlugin', () => { expect(callbackFalse).toHaveBeenCalledWith(); expect(doResolve).toHaveBeenCalledTimes(0); + // Internal modules with a path prefix of an external module + const callbackY = jest.fn(); + tap( + { + request: path.resolve(root, 'external-aa/src/module.ts'), + path: path.resolve(root, 'external-aa/src'), + context: { + issuer: path.resolve(root, 'external-aa/src/index.ts'), + }, + }, + 'some-context', + callbackY, + ); + expect(callbackY).toHaveBeenCalledTimes(1); + expect(callbackY).toHaveBeenCalledWith(); + expect(doResolve).toHaveBeenCalledTimes(0); + // External modules have their path and issuer context rewritten, but not the request const callbackA = jest.fn(); tap( diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts index 86269a5828..fed8b39a19 100644 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts @@ -16,6 +16,7 @@ import { resolve as resolvePath } from 'path'; import { ResolvePlugin } from 'webpack'; +import { isChildPath } from './paths'; import { LernaPackage } from './types'; // Enables proper resolution of packages when linking in external packages. @@ -40,7 +41,7 @@ export class LinkedPackageResolvePlugin implements ResolvePlugin { callback: () => void, ) => { const pkg = this.packages.find( - pkg => data.path && data.path.startsWith(pkg.location), + pkge => data.path && isChildPath(pkge.location, data.path), ); if (!pkg) { callback(); diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 3c336401ee..6fbcc65c9a 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -24,7 +24,7 @@ import webpack from 'webpack'; import nodeExternals from 'webpack-node-externals'; import { optimization } from './optimization'; import { Config } from '@backstage/config'; -import { BundlingPaths } from './paths'; +import { BundlingPaths, isChildPath } from './paths'; import { transforms } from './transforms'; import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; import { BundlingOptions, BackendBundlingOptions, LernaPackage } from './types'; @@ -87,7 +87,9 @@ export async function createConfig( const { plugins, loaders } = transforms(options); // Any package that is part of the monorepo but outside the monorepo root dir need // separate resolution logic. - const externalPkgs = packages.filter(p => !p.location.startsWith(paths.root)); + const externalPkgs = packages.filter( + p => !isChildPath(paths.root, p.location), + ); const baseUrl = frontendConfig.getString('app.baseUrl'); const validBaseUrl = new URL(baseUrl); @@ -199,7 +201,9 @@ export async function createBackendConfig( const moduleDirs = packages.map((p: any) => resolvePath(p.location, 'node_modules'), ); - const externalPkgs = packages.filter(p => !p.location.startsWith(paths.root)); // See frontend config + const externalPkgs = packages.filter( + p => !isChildPath(paths.root, p.location), + ); // See frontend config const { loaders } = transforms(options); @@ -215,7 +219,7 @@ export async function createBackendConfig( } : {}), externals: [ - nodeExternals({ + nodeExternalsWithResolve({ modulesDir: paths.rootNodeModules, additionalModuleDirs: moduleDirs, allowlist: ['webpack/hot/poll?100', ...localPackageNames], @@ -292,3 +296,32 @@ export async function createBackendConfig( ], }; } + +// This makes the module resolution happen from the context of each non-external module, rather +// than the main entrypoint. This fixes a bug where dependencies would be resolved from the backend +// package rather than each individual backend package and plugin. +// +// TODO(Rugvip): Feature suggestion/contribute this to webpack-externals +function nodeExternalsWithResolve( + options: Parameters[0], +) { + let currentContext: string; + const externals = nodeExternals({ + ...options, + importType(request) { + const resolved = require.resolve(request, { + paths: [currentContext], + }); + return `commonjs ${resolved}`; + }, + }); + + return ( + context: string, + request: string, + callback: webpack.ExternalsFunctionCallback, + ) => { + currentContext = context; + return externals(context, request, callback); + }; +} diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts index 390e41a345..c8d48a7199 100644 --- a/packages/cli/src/lib/bundler/paths.ts +++ b/packages/cli/src/lib/bundler/paths.ts @@ -15,8 +15,25 @@ */ import fs from 'fs-extra'; +import path from 'path'; import { paths } from '../paths'; +/** + * Checks if dir is the same as or a child of base. + */ +export function isChildPath(base: string, dir: string): boolean { + const relativePath = path.relative(base, dir); + if (relativePath === '') { + // The same directory + return true; + } + + const outsideBase = relativePath.startsWith('..'); // not outside base + const differentDrive = path.isAbsolute(relativePath); // on Windows, this means dir is on a different drive from base. + + return !outsideBase && !differentDrive; +} + export type BundlingPathsOptions = { // bundle entrypoint, e.g. 'src/index' entry: string; @@ -25,14 +42,14 @@ export type BundlingPathsOptions = { export function resolveBundlingPaths(options: BundlingPathsOptions) { const { entry } = options; - const resolveTargetModule = (path: string) => { + const resolveTargetModule = (pathString: string) => { for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) { - const filePath = paths.resolveTarget(`${path}.${ext}`); + const filePath = paths.resolveTarget(`${pathString}.${ext}`); if (fs.pathExistsSync(filePath)) { return filePath; } } - return paths.resolveTarget(`${path}.js`); + return paths.resolveTarget(`${pathString}.js`); }; let targetPublic = undefined; diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 88aac1e33c..30469421d3 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -21,6 +21,7 @@ import { paths } from './paths'; type Options = { args: string[]; fromPackage?: string; + mockEnv?: boolean; }; export async function loadCliConfig(options: Options) { @@ -40,7 +41,9 @@ export async function loadCliConfig(options: Options) { }); const appConfigs = await loadConfig({ - env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production', + experimentalEnvFunc: options.mockEnv + ? async name => process.env[name] || 'x' + : undefined, configRoot: paths.targetRoot, configPaths, }); diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index a1eab4c9e5..110a095fe3 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -44,3 +44,5 @@ export function exitWithError(error: Error): never { process.exit(1); } } + +export class NotFoundError extends CustomError {} diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index b0f1c46e8a..5af040809a 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -19,6 +19,7 @@ import path from 'path'; import * as runObj from '../run'; import { paths } from '../paths'; import { fetchPackageInfo, mapDependencies } from './packages'; +import { NotFoundError } from '../errors'; describe('fetchPackageInfo', () => { afterEach(() => { @@ -40,6 +41,14 @@ describe('fetchPackageInfo', () => { 'my-package', ); }); + + it('should throw if no info', async () => { + jest.spyOn(runObj, 'runPlain').mockResolvedValue(''); + + await expect(fetchPackageInfo('my-package')).rejects.toThrow( + new NotFoundError(`No package information found for package my-package`), + ); + }); }); describe('mapDependencies', () => { diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts index 76e5dc49b0..991a4d23ef 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli/src/lib/versioning/packages.ts @@ -15,6 +15,7 @@ */ import { runPlain } from '../../lib/run'; +import { NotFoundError } from '../errors'; const PREFIX = '@backstage'; @@ -49,6 +50,11 @@ export async function fetchPackageInfo( name: string, ): Promise { const output = await runPlain('yarn', 'info', '--json', name); + + if (!output) { + throw new NotFoundError(`No package information found for package ${name}`); + } + const info = JSON.parse(output) as YarnInfo; if (info.type !== 'inspect') { throw new Error(`Received unknown yarn info for ${name}, ${output}`); diff --git a/packages/cli/templates/default-plugin/dev/index.tsx b/packages/cli/templates/default-plugin/dev/index.tsx deleted file mode 100644 index 6fce113093..0000000000 --- a/packages/cli/templates/default-plugin/dev/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; - -createDevApp().registerPlugin(plugin).render(); diff --git a/packages/cli/templates/default-plugin/dev/index.tsx.hbs b/packages/cli/templates/default-plugin/dev/index.tsx.hbs new file mode 100644 index 0000000000..ade00a1613 --- /dev/null +++ b/packages/cli/templates/default-plugin/dev/index.tsx.hbs @@ -0,0 +1,11 @@ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { {{ pluginVar }}, {{ extensionName }} } from '../src/plugin'; + +createDevApp() + .registerPlugin({{ pluginVar }}) + .addPage({ + element: <{{ extensionName }} />, + title: 'Root Page', + }) + .render(); 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 e805900f36..e6eab17493 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,13 +1,12 @@ import React from 'react'; import { render } from '@testing-library/react'; -import ExampleComponent from './ExampleComponent'; +import { ExampleComponent } from './ExampleComponent'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { msw } from '@backstage/test-utils'; - describe('ExampleComponent', () => { const server = setupServer(); // Enable sane handlers for network requests @@ -15,15 +14,17 @@ describe('ExampleComponent', () => { // setup mock response beforeEach(() => { - server.use(rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({})))) - }) + server.use( + rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))), + ); + }); it('should render', () => { 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/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs index 5f90f2de1e..dcecebdf46 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs @@ -9,9 +9,9 @@ import { HeaderLabel, SupportButton, } from '@backstage/core'; -import ExampleFetchComponent from '../ExampleFetchComponent'; +import { ExampleFetchComponent } from '../ExampleFetchComponent'; -const ExampleComponent = () => ( +export const ExampleComponent = () => (
@@ -36,5 +36,3 @@ const ExampleComponent = () => ( ); - -export default ExampleComponent; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts index 520a3bf553..8b8437521b 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts @@ -1 +1 @@ -export { default } from './ExampleComponent'; +export { ExampleComponent } from './ExampleComponent'; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs index 81e1b4be09..fa1e289d8e 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,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import ExampleFetchComponent from './ExampleFetchComponent'; +import { ExampleFetchComponent } from './ExampleFetchComponent'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { msw } from '@backstage/test-utils'; @@ -9,11 +9,15 @@ describe('ExampleFetchComponent', () => { const server = setupServer(); // Enable sane handlers for network requests msw.setupDefaultHandlers(server); - + // setup mock response beforeEach(() => { - server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({})))) - }) + server.use( + rest.get('https://randomuser.me/*', (_, res, ctx) => + res(ctx.status(200), ctx.delay(2000), ctx.json({})), + ), + ); + }); it('should render', async () => { const rendered = render(); expect(await rendered.findByTestId('progress')).toBeInTheDocument(); diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs index 8cc5ed2ab7..20dd6d1f57 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs @@ -48,7 +48,7 @@ export const DenseTable = ({ users }: DenseTableProps) => { { title: 'Nationality', field: 'nationality' }, ]; - const data = users.map((user) => { + const data = users.map(user => { return { avatar: ( { ); }; -const ExampleFetchComponent = () => { +export const ExampleFetchComponent = () => { const { value, loading, error } = useAsync(async (): Promise => { const response = await fetch('https://randomuser.me/api/?results=20'); const data = await response.json(); @@ -88,5 +88,3 @@ const ExampleFetchComponent = () => { return ; }; - -export default ExampleFetchComponent; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts index 3e53453948..41a43e84f1 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts @@ -1 +1 @@ -export { default } from './ExampleFetchComponent'; +export { ExampleFetchComponent } from './ExampleFetchComponent'; diff --git a/packages/cli/templates/default-plugin/src/index.ts b/packages/cli/templates/default-plugin/src/index.ts deleted file mode 100644 index 99edba26c3..0000000000 --- a/packages/cli/templates/default-plugin/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { plugin } from './plugin'; diff --git a/packages/cli/templates/default-plugin/src/index.ts.hbs b/packages/cli/templates/default-plugin/src/index.ts.hbs new file mode 100644 index 0000000000..be4881efaf --- /dev/null +++ b/packages/cli/templates/default-plugin/src/index.ts.hbs @@ -0,0 +1 @@ +export { {{ pluginVar }}, {{ extensionName }} } from './plugin'; diff --git a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs index c2be0a5301..9d44a9c497 100644 --- a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs @@ -1,7 +1,7 @@ -import { plugin } from './plugin'; +import { {{ pluginVar }} } from './plugin'; describe('{{ id }}', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect({{ pluginVar }}).toBeDefined(); }); }); diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index 1bf4d07cdb..0ae5356fd1 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -1,14 +1,18 @@ -import { createPlugin, createRouteRef } from '@backstage/core'; -import ExampleComponent from './components/ExampleComponent'; +import { createPlugin, createRoutableExtension } from '@backstage/core'; -export const rootRouteRef = createRouteRef({ - path: '/{{ id }}', - title: '{{ id }}', -}); +import { rootRouteRef } from './routes'; -export const plugin = createPlugin({ +export const {{ pluginVar }} = createPlugin({ id: '{{ id }}', - register({ router }) { - router.addRoute(rootRouteRef, ExampleComponent); + routes: { + root: rootRouteRef, }, }); + +export const {{ extensionName }} = {{ pluginVar }}.provide( + createRoutableExtension({ + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + mountPoint: rootRouteRef, + }), +); diff --git a/packages/cli/templates/default-plugin/src/routes.ts.hbs b/packages/cli/templates/default-plugin/src/routes.ts.hbs new file mode 100644 index 0000000000..b2afba074d --- /dev/null +++ b/packages/cli/templates/default-plugin/src/routes.ts.hbs @@ -0,0 +1,5 @@ +import { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + title: '{{ id }}', +}); diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index 30891c2dd9..cbc6473286 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,51 @@ # @backstage/config-loader +## 0.5.1 + +### Patch Changes + +- 062df71db: Bump `config-loader` to `ajv` 7, to enable v7 feature use elsewhere +- e9aab60c7: Each piece of the configuration schema is now validated upfront, in order to produce more informative errors. + +## 0.5.0 + +### Minor Changes + +- ef7957be4: Removed support for the deprecated `$data` placeholder. +- ef7957be4: Enable further processing of configuration files included using the `$include` placeholder. Meaning that for example for example `$env` includes will be processed as usual in included files. + +### Patch Changes + +- ef7957be4: Added support for environment variable substitutions in string configuration values using a `${VAR}` placeholder. All environment variables must be available, or the entire expression will be evaluated to `undefined`. To escape a substitution, use `${...}`, which will end up as `${...}`. + + For example: + + ```yaml + app: + baseUrl: https://${BASE_HOST} + ``` + +## 0.4.1 + +### Patch Changes + +- ad5c56fd9: Deprecate `$data` and replace it with `$include` which allows for any type of json value to be read from external files. In addition, `$include` can be used without a path, which causes the value at the root of the file to be loaded. + + Most usages of `$data` can be directly replaced with `$include`, except if the referenced value is not a string, in which case the value needs to be changed. For example: + + ```yaml + # app-config.yaml + foo: + $data: foo.yaml#myValue # replacing with $include will turn the value into a number + $data: bar.yaml#myValue # replacing with $include is safe + + # foo.yaml + myValue: 0xf00 + + # bar.yaml + myValue: bar + ``` + ## 0.4.0 ### Minor Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 3e2c58c676..0b024b2c84 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.4.0", + "version": "0.5.1", "private": false, "publishConfig": { "access": "public", @@ -32,11 +32,11 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.1", - "ajv": "^6.12.5", + "ajv": "^7.0.3", "fs-extra": "^9.0.0", "json-schema": "^0.2.5", "json-schema-merge-allof": "^0.7.0", - "typescript-json-schema": "^0.45.0", + "typescript-json-schema": "^0.47.0", "yaml": "^1.9.2", "yup": "^0.29.3" }, diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts index ceb7c34222..192ac81f5d 100644 --- a/packages/config-loader/src/lib/index.ts +++ b/packages/config-loader/src/lib/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export { readConfigFile } from './reader'; export { readEnvConfig } from './env'; -export { readSecret } from './secrets'; +export * from './transform'; export * from './schema'; diff --git a/packages/config-loader/src/lib/reader.test.ts b/packages/config-loader/src/lib/reader.test.ts deleted file mode 100644 index a0a8495714..0000000000 --- a/packages/config-loader/src/lib/reader.test.ts +++ /dev/null @@ -1,181 +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 { readConfigFile } from './reader'; -import { ReaderContext, ReadSecretFunc } from './types'; - -function memoryFiles(files: { [path: string]: string }) { - return async (path: string) => { - if (path in files) { - return files[path]; - } - throw new Error(`File not found, ${path}`); - }; -} - -const mockContext: ReaderContext = { - env: {}, - readFile: jest.fn(), - readSecret: jest.fn(), -}; - -describe('readConfigFile', () => { - it('should read a plain config file', async () => { - const readFile = memoryFiles({ - './app-config.yaml': - 'app: { title: "Test", x: 1, y: [null, true], z: null }', - }); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - }); - - await expect(config).resolves.toEqual({ - data: { - app: { - title: 'Test', - x: 1, - y: [true], - }, - }, - context: 'app-config.yaml', - }); - }); - - it('should error out if the config file has invalid syntax', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { title: ]', - }); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - }); - - await expect(config).rejects.toThrow('Flow map contains an unexpected ]'); - }); - - it('should error out if config is not an object', async () => { - const readFile = memoryFiles({ - './app-config.yaml': '[]', - }); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - }); - - await expect(config).rejects.toThrow('Expected object at config root'); - }); - - 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" } }', - }); - 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 require deprecated secrets to be objects', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { $secret: ["wrong-type"] }', - }); - const readSecret = jest.fn().mockResolvedValue('secret'); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - readSecret: readSecret as ReadSecretFunc, - }); - - expect(readSecret).not.toHaveBeenCalled(); - await expect(config).rejects.toThrow( - 'Expected object at secret .app.$secret', - ); - }); - - it('should forward secret reading errors', async () => { - const readFile = memoryFiles({ - './app-config.yaml': 'app: { $secret: {} }', - }); - const readSecret = jest.fn().mockRejectedValue(new Error('NOPE')); - - const config = readConfigFile('./app-config.yaml', { - ...mockContext, - readFile, - readSecret: readSecret as ReadSecretFunc, - }); - - await expect(config).rejects.toThrow('Invalid secret at .app: NOPE'); - }); -}); diff --git a/packages/config-loader/src/lib/reader.ts b/packages/config-loader/src/lib/reader.ts deleted file mode 100644 index 9eba58be97..0000000000 --- a/packages/config-loader/src/lib/reader.ts +++ /dev/null @@ -1,111 +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 { AppConfig, JsonObject, JsonValue } from '@backstage/config'; -import { basename } from 'path'; -import yaml from 'yaml'; -import { ReaderContext } from './types'; -import { isObject } from './utils'; - -/** - * Reads and parses, and validates, and transforms a single config file. - * The transformation rewrites any special values, like the $secret key. - */ -export async function readConfigFile( - filePath: string, - ctx: ReaderContext, -): Promise { - const configYaml = await ctx.readFile(filePath); - const config = yaml.parse(configYaml); - - const context = basename(filePath); - - async function transform( - obj: JsonValue, - path: string, - ): Promise { - if (typeof obj !== 'object') { - return obj; - } else if (obj === null) { - return undefined; - } else if (Array.isArray(obj)) { - const arr = new Array(); - - for (const [index, value] of obj.entries()) { - const out = await transform(value, `${path}[${index}]`); - if (out !== undefined) { - arr.push(out); - } - } - - return arr; - } - - // 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`); - } - - try { - return await ctx.readSecret(path, obj.$secret); - } catch (error) { - throw new Error(`Invalid secret at ${path}: ${error.message}`); - } - } - - // 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)) { - // undefined covers optional fields - if (value !== undefined) { - const result = await transform(value, `${path}.${key}`); - if (result !== undefined) { - out[key] = result; - } - } - } - - return out; - } - - const finalConfig = await transform(config, ''); - if (!isObject(finalConfig)) { - throw new TypeError('Expected object at config root'); - } - return { data: finalConfig, context }; -} diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts index 0e53562875..611413931e 100644 --- a/packages/config-loader/src/lib/schema/collect.ts +++ b/packages/config-loader/src/lib/schema/collect.ts @@ -118,7 +118,9 @@ export async function collectConfigSchemas( } await Promise.all( - depNames.map(name => processItem({ name, parentPath: pkgPath })), + depNames.map(depName => + processItem({ name: depName, parentPath: pkgPath }), + ), ); } diff --git a/packages/config-loader/src/lib/schema/compile.test.ts b/packages/config-loader/src/lib/schema/compile.test.ts index f03d7e6d10..e1d8ee5999 100644 --- a/packages/config-loader/src/lib/schema/compile.test.ts +++ b/packages/config-loader/src/lib/schema/compile.test.ts @@ -29,11 +29,11 @@ describe('compileConfigSchemas', () => { }, ]); expect(validate([{ data: { a: 1 }, context: 'test' }])).toEqual({ - errors: ['Config should be string { type=string } at .a'], + errors: ['Config should be string { type=string } at /a'], visibilityByPath: new Map(), }); expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({ - errors: ['Config should be number { type=number } at .b'], + errors: ['Config should be number { type=number } at /b'], visibilityByPath: new Map(), }); }); @@ -80,10 +80,10 @@ describe('compileConfigSchemas', () => { ).toEqual({ visibilityByPath: new Map( Object.entries({ - '.a': 'frontend', - '.b': 'secret', - '.d': 'secret', - '.d.0': 'frontend', + '/a': 'frontend', + '/b': 'secret', + '/d': 'secret', + '/d/0': 'frontend', }), ), }); diff --git a/packages/config-loader/src/lib/schema/compile.ts b/packages/config-loader/src/lib/schema/compile.ts index f01a4f640a..e340d775a1 100644 --- a/packages/config-loader/src/lib/schema/compile.ts +++ b/packages/config-loader/src/lib/schema/compile.ts @@ -42,23 +42,25 @@ export function compileConfigSchemas( const ajv = new Ajv({ allErrors: true, + allowUnionTypes: true, schemas: { 'https://backstage.io/schema/config-v1': true, }, - }).addKeyword('visibility', { + }).addKeyword({ + keyword: 'visibility', metaSchema: { type: 'string', enum: CONFIG_VISIBILITIES, }, compile(visibility: ConfigVisibility) { - return (_data, dataPath) => { - if (!dataPath) { + return (_data, context) => { + if (context?.dataPath === undefined) { return false; } if (visibility && visibility !== 'backend') { - const normalizedPath = dataPath.replace( + const normalizedPath = context.dataPath.replace( /\['?(.*?)'?\]/g, - (_, segment) => `.${segment}`, + (_, segment) => `/${segment}`, ); visibilityByPath.set(normalizedPath, visibility); } @@ -67,6 +69,14 @@ export function compileConfigSchemas( }, }); + for (const schema of schemas) { + try { + ajv.compile(schema.value); + } catch (error) { + throw new Error(`Schema at ${schema.path} is invalid, ${error}`); + } + } + const merged = mergeAllOf( { allOf: schemas.map(_ => _.value) }, { diff --git a/packages/config-loader/src/lib/schema/filtering.test.ts b/packages/config-loader/src/lib/schema/filtering.test.ts index f6926e3560..feb31ebab0 100644 --- a/packages/config-loader/src/lib/schema/filtering.test.ts +++ b/packages/config-loader/src/lib/schema/filtering.test.ts @@ -40,24 +40,24 @@ const data = { const visibility = new Map( Object.entries({ - '.arr.0': 'frontend', - '.arr.1': 'backend', - '.arr.2': 'secret', - '.obj.f': 'frontend', - '.obj.b': 'backend', - '.obj.b.s': 'secret', - '.objArr.0.f': 'frontend', - '.objArr.0.b': 'backend', - '.objArr.0.s': 'secret', - '.objArr.1.f': 'frontend', - '.objArr.1.b': 'backend', - '.objArr.1.s': 'secret', - '.arrF': 'frontend', - '.arrB': 'backend', - '.arrS': 'secret', - '.objF': 'frontend', - '.objB': 'backend', - '.objS': 'secret', + '/arr/0': 'frontend', + '/arr/1': 'backend', + '/arr/2': 'secret', + '/obj/f': 'frontend', + '/obj/b': 'backend', + '/obj/b/s': 'secret', + '/objArr/0/f': 'frontend', + '/objArr/0/b': 'backend', + '/objArr/0/s': 'secret', + '/objArr/1/f': 'frontend', + '/objArr/1/b': 'backend', + '/objArr/1/s': 'secret', + '/arrF': 'frontend', + '/arrB': 'backend', + '/arrS': 'secret', + '/objF': 'frontend', + '/objB': 'backend', + '/objS': 'secret', }), ); diff --git a/packages/config-loader/src/lib/schema/filtering.ts b/packages/config-loader/src/lib/schema/filtering.ts index 10a97f9a7f..93e899c9d8 100644 --- a/packages/config-loader/src/lib/schema/filtering.ts +++ b/packages/config-loader/src/lib/schema/filtering.ts @@ -49,7 +49,7 @@ export function filterByVisibility( const arr = new Array(); for (const [index, value] of jsonVal.entries()) { - const out = transform(value, `${path}.${index}`); + const out = transform(value, `${path}/${index}`); if (out !== undefined) { arr.push(out); } @@ -68,7 +68,7 @@ export function filterByVisibility( if (value === undefined) { continue; } - const out = transform(value, `${path}.${key}`); + const out = transform(value, `${path}/${key}`); if (out !== undefined) { outObj[key] = out; hasOutput = true; diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts index baf63525bb..7d9f7cc803 100644 --- a/packages/config-loader/src/lib/schema/load.test.ts +++ b/packages/config-loader/src/lib/schema/load.test.ts @@ -85,7 +85,7 @@ describe('loadConfigSchema', () => { expect(() => schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]), ).toThrow( - 'Config validation failed, Config should be string { type=string } at .key1', + 'Config validation failed, Config should be string { type=string } at /key1', ); await expect( diff --git a/packages/config-loader/src/lib/secrets.test.ts b/packages/config-loader/src/lib/secrets.test.ts deleted file mode 100644 index 189834f3aa..0000000000 --- a/packages/config-loader/src/lib/secrets.test.ts +++ /dev/null @@ -1,162 +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 { readSecret } from './secrets'; -import { ReaderContext } from './types'; - -const ctx: ReaderContext = { - env: { - SECRET: 'my-secret', - }, - readSecret: jest.fn(), - async readFile(path) { - const content = ({ - 'my-secret': 'secret', - 'my-data.json': '{"a":{"b":{"c":42}}}', - 'my-data.yaml': 'some:\n yaml:\n key: 7', - 'my-data.yml': 'different: { key: hello }', - 'invalid.yaml': 'foo: [}', - } as { [key: string]: string })[path]; - - if (!content) { - throw new Error('File not found!'); - } - return content; - }, -}; - -describe('readSecret', () => { - it('should read file secrets', async () => { - await expect(readSecret({ file: 'my-secret' }, ctx)).resolves.toBe( - 'secret', - ); - await expect(readSecret({ file: 'no-secret' }, ctx)).rejects.toThrow( - 'File not found!', - ); - }); - - it('should read present env secrets', async () => { - await expect(readSecret({ env: 'SECRET' }, ctx)).resolves.toBe('my-secret'); - await expect(readSecret({ env: 'NO_SECRET' }, ctx)).resolves.toBe( - undefined, - ); - }); - - it('should read data secrets', async () => { - // 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 include extra files', async () => { - // New format with path in fragment - await expect( - readSecret({ include: 'my-data.json#a.b.c' }, ctx), - ).resolves.toBe(42); - await expect( - readSecret({ include: 'my-data.json#a.b' }, ctx), - ).resolves.toEqual({ c: 42 }); - await expect( - readSecret({ include: 'my-data.yaml#some.yaml.key' }, ctx), - ).resolves.toBe(7); - await expect(readSecret({ include: 'my-data.yaml' }, ctx)).resolves.toEqual( - { - some: { yaml: { key: 7 } }, - }, - ); - await expect( - readSecret({ include: 'my-data.yaml#' }, ctx), - ).resolves.toEqual({ - some: { yaml: { key: 7 } }, - }); - await expect( - readSecret({ include: 'my-data.yml#different.key' }, ctx), - ).resolves.toBe('hello'); - await expect( - readSecret({ include: 'no-data.yml#different.key' }, ctx), - ).rejects.toThrow('File not found!'); - await expect( - readSecret({ include: 'my-data.yml#missing.key' }, ctx), - ).rejects.toThrow('Value is not an object at missing in my-data.yml'); - await expect(readSecret({ include: 'invalid.yaml' }, ctx)).rejects.toThrow( - 'Failed to parse included file invalid.yaml, YAMLSyntaxError: Flow sequence contains an unexpected }', - ); - }); - - it('should reject invalid secrets', async () => { - await expect(readSecret('hello' as any, ctx)).rejects.toThrow( - 'secret must be a `object` type, but the final value was: `"hello"`.', - ); - await expect(readSecret({}, ctx)).rejects.toThrow( - "Secret must contain one of 'file', 'env', 'data'", - ); - await expect(readSecret({ unknown: 'derp' }, ctx)).rejects.toThrow( - "Secret must contain one of 'file', 'env', 'data'", - ); - await expect(readSecret({ data: 'no-data.yml' }, ctx)).rejects.toThrow( - "Invalid format for data secret value, must be of the form #, got 'no-data.yml'", - ); - await expect( - readSecret({ data: 'no-parser.js', path: '.' }, ctx), - ).rejects.toThrow('No data secret parser available for extension .js'); - await expect( - readSecret({ data: 'my-data.yaml', path: 'some.wrong.yaml.key' }, ctx), - ).rejects.toThrow('Value is not an object at some.wrong in my-data.yaml'); - }); - - it('should have 100% test coverage', async () => { - let firstVisit = true; - const secret = {}; - const proto = { - get file() { - if (!firstVisit) { - Object.setPrototypeOf(secret, {}); - } - firstVisit = false; - return 'a-file'; - }, - }; - Object.setPrototypeOf(secret, proto); - - await expect(readSecret(secret, ctx)).rejects.toThrow( - 'Secret was left unhandled', - ); - }); -}); diff --git a/packages/config-loader/src/lib/secrets.ts b/packages/config-loader/src/lib/secrets.ts deleted file mode 100644 index b0e0a0dd7b..0000000000 --- a/packages/config-loader/src/lib/secrets.ts +++ /dev/null @@ -1,180 +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 * as yup from 'yup'; -import yaml from 'yaml'; -import { extname } from 'path'; -import { JsonObject, JsonValue } from '@backstage/config'; -import { isObject, isNever } from './utils'; -import { ReaderContext } from './types'; - -// Reads a file and forwards the contents as is, assuming ut8 encoding -type FileSecret = { - // Path to the secret file, relative to the config file. - file: string; -}; - -// Reads the secret from an environment variable. -type EnvSecret = { - // The name of the environment file. - env: string; -}; - -// Reads a secret from a json-like file and extracts a value at a path. -// The supported extensions are define in dataSecretParser below. -type DataSecret = { - // Path to the data secret file, relative to the config file. - data: string; - // The path to the value inside the data file, each element separated by '.'. - path?: string; -}; - -// TODO(Rugvip): Move this out of secret reading when we remove the deprecated DataSecret and $secret format -type IncludeSecret = { - include: string; -}; - -type Secret = FileSecret | EnvSecret | DataSecret | IncludeSecret; - -// Schema for each type of secret description -const secretLoaderSchemas = { - file: yup.object({ - file: yup.string().required(), - }), - env: yup.object({ - env: yup.string().required(), - }), - data: yup.object({ - data: yup.string().required(), - }), - include: yup.object({ - include: yup.string().required(), - }), -}; - -// The top-level secret schema, which figures out what type of secret it is. -const secretSchema = yup.lazy(value => { - if (typeof value !== 'object' || value === null) { - return yup.object().required().label('secret'); - } - - const loaderTypes = Object.keys( - secretLoaderSchemas, - ) as (keyof typeof secretLoaderSchemas)[]; - - for (const key of loaderTypes) { - if (key in value) { - return secretLoaderSchemas[key]; - } - } - throw new yup.ValidationError( - `Secret must contain one of '${loaderTypes.join("', '")}'`, - value, - '$secret', - ); -}); - -// Parsers for each type of data secret file. -const dataSecretParser: { - [ext in string]: (content: string) => Promise; -} = { - '.json': async content => JSON.parse(content), - '.yaml': async content => yaml.parse(content), - '.yml': async content => yaml.parse(content), -}; - -/** - * Transforms a secret description into the actual secret value. - */ -export async function readSecret( - data: JsonObject, - ctx: ReaderContext, -): Promise { - const secret = secretSchema.validateSync(data, { strict: true }) as Secret; - - if ('file' in secret) { - return ctx.readFile(secret.file); - } - if ('env' in secret) { - return ctx.env[secret.env]; - } - if ('data' in secret) { - console.warn( - `Configuration uses deprecated $data key, use $include instead.`, - ); - 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(filePath); - - 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 ${filePath}`); - } - value = value[part]; - } - - return String(value); - } - if ('include' in secret) { - const [filePath, dataPath] = secret.include.split(/#(.*)/); - - 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(filePath); - - const parts = dataPath ? dataPath.split('.') : []; - - let value: JsonValue | undefined; - try { - value = await parser(content); - } catch (error) { - throw new Error(`Failed to parse included file ${filePath}, ${error}`); - } - 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 ${filePath}`); - } - value = value[part]; - } - - return value; - } - - isNever(); - throw new Error('Secret was left unhandled'); -} diff --git a/packages/config-loader/src/lib/transform/apply.test.ts b/packages/config-loader/src/lib/transform/apply.test.ts new file mode 100644 index 0000000000..4cdd0e97f5 --- /dev/null +++ b/packages/config-loader/src/lib/transform/apply.test.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 { applyConfigTransforms } from './apply'; + +describe('applyConfigTransforms', () => { + it('should apply not transforms to input', async () => { + const data = applyConfigTransforms( + '', + { + app: { + title: 'Test', + x: 1, + y: [null, true], + z: null, + }, + }, + [], + ); + + await expect(data).resolves.toEqual({ + app: { + title: 'Test', + x: 1, + y: [true], + }, + }); + }); + + it('should throw if input is not an object', async () => { + const config = applyConfigTransforms('', 'not-config', []); + + await expect(config).rejects.toThrow('expected object at config root'); + }); + + it('should apply transforms', async () => { + const config = applyConfigTransforms( + '', + { + app: { + title: 'Test', + x: 1, + y: [null, true], + z: null, + }, + }, + [ + async value => { + if (typeof value === 'number') { + return { applied: true, value: value + 1 }; + } + return { applied: false }; + }, + async value => { + if (typeof value === 'string' && value.length > 1) { + return { applied: true, value: value.split('') }; + } + return { applied: false }; + }, + ], + ); + + await expect(config).resolves.toEqual({ + app: { + title: ['T', 'e', 's', 't'], + x: 2, + y: [true], + }, + }); + }); +}); diff --git a/packages/config-loader/src/lib/transform/apply.ts b/packages/config-loader/src/lib/transform/apply.ts new file mode 100644 index 0000000000..72c440a922 --- /dev/null +++ b/packages/config-loader/src/lib/transform/apply.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject, JsonValue } from '@backstage/config'; +import { TransformFunc } from './types'; +import { isObject } from './utils'; + +/** + * Applies a set of transforms to raw configuration data. + */ +export async function applyConfigTransforms( + initialDir: string, + input: JsonValue, + transforms: TransformFunc[], +): Promise { + async function transform( + inputObj: JsonValue, + path: string, + baseDir: string, + ): Promise { + let obj = inputObj; + let dir = baseDir; + + for (const tf of transforms) { + try { + const result = await tf(inputObj, baseDir); + if (result.applied) { + if (result.value === undefined) { + return undefined; + } + obj = result.value; + dir = result.newBaseDir ?? dir; + break; + } + } catch (error) { + throw new Error(`error at ${path}, ${error.message}`); + } + } + + if (typeof obj !== 'object') { + return obj; + } else if (obj === null) { + return undefined; + } else if (Array.isArray(obj)) { + const arr = new Array(); + + for (const [index, value] of obj.entries()) { + const out = await transform(value, `${path}[${index}]`, dir); + if (out !== undefined) { + arr.push(out); + } + } + + return arr; + } + + const out: JsonObject = {}; + + for (const [key, value] of Object.entries(obj)) { + // undefined covers optional fields + if (value !== undefined) { + const result = await transform(value, `${path}.${key}`, dir); + if (result !== undefined) { + out[key] = result; + } + } + } + + return out; + } + + const finalData = await transform(input, '', initialDir); + if (!isObject(finalData)) { + throw new TypeError('expected object at config root'); + } + return finalData; +} diff --git a/packages/config-loader/src/lib/transform/include.test.ts b/packages/config-loader/src/lib/transform/include.test.ts new file mode 100644 index 0000000000..1724159e56 --- /dev/null +++ b/packages/config-loader/src/lib/transform/include.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 os from 'os'; +import { resolve as resolvePath } from 'path'; +import { createIncludeTransform } from './include'; + +const root = os.platform() === 'win32' ? 'C:\\' : '/'; + +const env = jest.fn(async (name: string) => { + return ({ + SECRET: 'my-secret', + } as { [name: string]: string })[name]; +}); + +const readFile = jest.fn(async (path: string) => { + const content = ({ + [resolvePath(root, 'my-secret')]: 'secret', + [resolvePath(root, 'my-data.json')]: '{"a":{"b":{"c":42}}}', + [resolvePath(root, 'my-data.yaml')]: 'some:\n yaml:\n key: 7', + [resolvePath(root, 'my-data.yml')]: 'different: { key: hello }', + [resolvePath(root, 'invalid.yaml')]: 'foo: [}', + } as { [key: string]: string })[path]; + + if (!content) { + throw new Error('File not found!'); + } + return content; +}); + +const includeTransform = createIncludeTransform(env, readFile); + +describe('includeTransform', () => { + it('should not transform unknown values', async () => { + await expect(includeTransform('foo', root)).resolves.toEqual({ + applied: false, + }); + await expect(includeTransform([1], root)).resolves.toEqual({ + applied: false, + }); + await expect(includeTransform(1, root)).resolves.toEqual({ + applied: false, + }); + await expect(includeTransform({ x: 'y' }, root)).resolves.toEqual({ + applied: false, + }); + await expect(includeTransform(null, root)).resolves.toEqual({ + applied: false, + }); + }); + + it('should include text files', async () => { + await expect( + includeTransform({ $file: 'my-secret' }, root), + ).resolves.toEqual({ applied: true, value: 'secret' }); + await expect( + includeTransform({ $file: 'no-secret' }, root), + ).rejects.toThrow('File not found!'); + }); + + it('should include env vars', async () => { + await expect(includeTransform({ $env: 'SECRET' }, root)).resolves.toEqual({ + applied: true, + value: 'my-secret', + }); + await expect( + includeTransform({ $env: 'NO_SECRET' }, root), + ).resolves.toEqual({ + applied: true, + value: undefined, + }); + }); + + it('should include config files', async () => { + // New format with path in fragment + await expect( + includeTransform({ $include: 'my-data.json#a.b.c' }, root), + ).resolves.toEqual({ applied: true, value: 42 }); + await expect( + includeTransform({ $include: 'my-data.json#a.b' }, root), + ).resolves.toEqual({ applied: true, value: { c: 42 } }); + await expect( + includeTransform({ $include: 'my-data.yaml#some.yaml.key' }, root), + ).resolves.toEqual({ applied: true, value: 7 }); + await expect( + includeTransform({ $include: 'my-data.yaml' }, root), + ).resolves.toEqual({ + applied: true, + value: { + some: { yaml: { key: 7 } }, + }, + }); + await expect( + includeTransform({ $include: 'my-data.yaml#' }, root), + ).resolves.toEqual({ + applied: true, + value: { + some: { yaml: { key: 7 } }, + }, + }); + await expect( + includeTransform({ $include: 'my-data.yml#different.key' }, root), + ).resolves.toEqual({ applied: true, value: 'hello' }); + }); + + it('should reject invalid includes', async () => { + await expect( + includeTransform({ $include: 'no-parser.js' }, root), + ).rejects.toThrow( + 'no configuration parser available for included file no-parser.js', + ); + await expect( + includeTransform({ $include: 'no-data.yml#different.key' }, root), + ).rejects.toThrow('File not found!'); + await expect( + includeTransform({ $include: 'my-data.yml#missing.key' }, root), + ).rejects.toThrow( + "value at 'missing' in included file my-data.yml is not an object", + ); + await expect( + includeTransform({ $include: 'invalid.yaml' }, root), + ).rejects.toThrow( + 'failed to parse included file invalid.yaml, YAMLSyntaxError: Flow sequence contains an unexpected }', + ); + }); +}); diff --git a/packages/config-loader/src/lib/transform/include.ts b/packages/config-loader/src/lib/transform/include.ts new file mode 100644 index 0000000000..1a6963672a --- /dev/null +++ b/packages/config-loader/src/lib/transform/include.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 yaml from 'yaml'; +import { extname, dirname, resolve as resolvePath } from 'path'; +import { JsonObject, JsonValue } from '@backstage/config'; +import { isObject } from './utils'; +import { TransformFunc, EnvFunc, ReadFileFunc } from './types'; + +// Parsers for each type of included file +const includeFileParser: { + [ext in string]: (content: string) => Promise; +} = { + '.json': async content => JSON.parse(content), + '.yaml': async content => yaml.parse(content), + '.yml': async content => yaml.parse(content), +}; + +/** + * Transforms a include description into the actual included value. + */ +export function createIncludeTransform( + env: EnvFunc, + readFile: ReadFileFunc, +): TransformFunc { + return async (input: JsonValue, baseDir: string) => { + if (!isObject(input)) { + return { applied: false }; + } + // Check if there's any key that starts with a '$', in that case we treat + // this entire object as an include description. + const [includeKey] = Object.keys(input).filter(key => key.startsWith('$')); + if (includeKey) { + if (Object.keys(input).length !== 1) { + throw new Error( + `include key ${includeKey} should not have adjacent keys`, + ); + } + } else { + return { applied: false }; + } + + const includeValue = input[includeKey]; + if (typeof includeValue !== 'string') { + throw new Error(`${includeKey} include value is not a string`); + } + + switch (includeKey) { + case '$file': + try { + const value = await readFile(resolvePath(baseDir, includeValue)); + return { applied: true, value }; + } catch (error) { + throw new Error(`failed to read file ${includeValue}, ${error}`); + } + case '$env': + try { + return { applied: true, value: await env(includeValue) }; + } catch (error) { + throw new Error(`failed to read env ${includeValue}, ${error}`); + } + + case '$include': { + const [filePath, dataPath] = includeValue.split(/#(.*)/); + + const ext = extname(filePath); + const parser = includeFileParser[ext]; + if (!parser) { + throw new Error( + `no configuration parser available for included file ${filePath}`, + ); + } + + const path = resolvePath(baseDir, filePath); + const content = await readFile(path); + const newBaseDir = dirname(path); + + const parts = dataPath ? dataPath.split('.') : []; + + let value: JsonValue | undefined; + try { + value = await parser(content); + } catch (error) { + throw new Error( + `failed to parse included file ${filePath}, ${error}`, + ); + } + + // This bit handles selecting a subtree in the included file, if a path was provided after a # + for (const [index, part] of parts.entries()) { + if (!isObject(value)) { + const errPath = parts.slice(0, index).join('.'); + throw new Error( + `value at '${errPath}' in included file ${filePath} is not an object`, + ); + } + value = value[part]; + } + + return { + applied: true, + value, + newBaseDir: newBaseDir !== baseDir ? newBaseDir : undefined, + }; + } + + default: + throw new Error(`unknown include ${includeKey}`); + } + }; +} diff --git a/packages/config-loader/src/lib/transform/index.ts b/packages/config-loader/src/lib/transform/index.ts new file mode 100644 index 0000000000..cb9f077d43 --- /dev/null +++ b/packages/config-loader/src/lib/transform/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 { applyConfigTransforms } from './apply'; +export { createIncludeTransform } from './include'; +export { createSubstitutionTransform } from './substitution'; diff --git a/packages/config-loader/src/lib/transform/substitution.test.ts b/packages/config-loader/src/lib/transform/substitution.test.ts new file mode 100644 index 0000000000..e2c0200bfd --- /dev/null +++ b/packages/config-loader/src/lib/transform/substitution.test.ts @@ -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 { createSubstitutionTransform } from './substitution'; + +const env = jest.fn(async (name: string) => { + return ({ + SECRET: 'my-secret', + TOKEN: 'my-token', + } as { [name: string]: string })[name]; +}); + +const substituteTransform = createSubstitutionTransform(env); + +describe('substituteTransform', () => { + it('should not transform unknown values', async () => { + await expect(substituteTransform(false, '/')).resolves.toEqual({ + applied: false, + }); + await expect(substituteTransform([1], '/')).resolves.toEqual({ + applied: false, + }); + await expect(substituteTransform(1, '/')).resolves.toEqual({ + applied: false, + }); + await expect(substituteTransform({ x: 'y' }, '/')).resolves.toEqual({ + applied: false, + }); + await expect(substituteTransform(null, '/')).resolves.toEqual({ + applied: false, + }); + }); + + it('should substitute env var', async () => { + await expect(substituteTransform('hello ${SECRET}', '/')).resolves.toEqual({ + applied: true, + value: 'hello my-secret', + }); + await expect( + substituteTransform('${SECRET } $${} ${TOKEN }', '/'), + ).resolves.toEqual({ applied: true, value: 'my-secret $${} my-token' }); + await expect(substituteTransform('foo ${MISSING}', '/')).resolves.toEqual({ + applied: true, + value: undefined, + }); + await expect( + substituteTransform('foo ${MISSING} ${SECRET}', '/'), + ).resolves.toEqual({ applied: true, value: undefined }); + await expect( + substituteTransform('foo ${SECRET} ${SECRET}', '/'), + ).resolves.toEqual({ applied: true, value: 'foo my-secret my-secret' }); + }); +}); diff --git a/packages/config-loader/src/lib/transform/substitution.ts b/packages/config-loader/src/lib/transform/substitution.ts new file mode 100644 index 0000000000..821cc8bc67 --- /dev/null +++ b/packages/config-loader/src/lib/transform/substitution.ts @@ -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 { JsonValue } from '@backstage/config'; +import { TransformFunc, EnvFunc } from './types'; + +/** + * A environment variable substitution transform that transforms e.g. 'token ${MY_TOKEN}' + * to 'token abc' if MY_TOKEN is 'abc'. If any of the substituted variables are undefined, + * the entire expression ends up undefined. + */ +export function createSubstitutionTransform(env: EnvFunc): TransformFunc { + return async (input: JsonValue) => { + if (typeof input !== 'string') { + return { applied: false }; + } + + const parts: (string | undefined)[] = input.split(/(? part === undefined)) { + return { applied: true, value: undefined }; + } + return { applied: true, value: parts.join('') }; + }; +} diff --git a/packages/config-loader/src/lib/types.ts b/packages/config-loader/src/lib/transform/types.ts similarity index 60% rename from packages/config-loader/src/lib/types.ts rename to packages/config-loader/src/lib/transform/types.ts index 95e1d6655e..c13f01d016 100644 --- a/packages/config-loader/src/lib/types.ts +++ b/packages/config-loader/src/lib/transform/types.ts @@ -14,20 +14,22 @@ * limitations under the License. */ -import { JsonObject, JsonValue } from '@backstage/config'; +import { JsonValue } from '@backstage/config'; + +export type EnvFunc = (name: string) => Promise; export type ReadFileFunc = (path: string) => Promise; -export type ReadSecretFunc = ( - path: string, - desc: JsonObject, -) => Promise; -export type SkipFunc = (path: string) => boolean; -/** - * Common context that provides all the necessary hooks for reading configuration files. - */ -export type ReaderContext = { - env: { [name in string]?: string }; - readFile: ReadFileFunc; - readSecret: ReadSecretFunc; -}; +export type TransformFunc = ( + value: JsonValue, + baseDir: string, +) => Promise< + | { + applied: false; + } + | { + applied: true; + value: JsonValue | undefined; + newBaseDir?: string | undefined; + } +>; diff --git a/packages/config-loader/src/lib/utils.ts b/packages/config-loader/src/lib/transform/utils.ts similarity index 86% rename from packages/config-loader/src/lib/utils.ts rename to packages/config-loader/src/lib/transform/utils.ts index 37145ec971..9a72bc3c0e 100644 --- a/packages/config-loader/src/lib/utils.ts +++ b/packages/config-loader/src/lib/transform/utils.ts @@ -24,8 +24,3 @@ export function isObject(obj: JsonValue | undefined): obj is JsonObject { } return obj !== null; } - -// A thing to make sure we've narrowed the type down to never -export function isNever() { - return void 0 as T; -} diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index a9857a784f..8c7de557e3 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -19,6 +19,8 @@ import mockFs from 'mock-fs'; describe('loadConfig', () => { beforeAll(() => { + process.env.MY_SECRET = 'is-secret'; + mockFs({ '/root/app-config.yaml': ` app: @@ -29,8 +31,20 @@ describe('loadConfig', () => { '/root/app-config.development.yaml': ` app: sessionKey: development-key + backend: + $include: ./included.yaml + other: + $include: secrets/included.yaml `, '/root/secrets/session-key.txt': 'abc123', + '/root/secrets/included.yaml': ` + secret: + $file: session-key.txt + `, + '/root/included.yaml': ` + foo: + bar: token \${MY_SECRET} + `, }); }); @@ -104,6 +118,14 @@ describe('loadConfig', () => { app: { sessionKey: 'development-key', }, + backend: { + foo: { + bar: 'token is-secret', + }, + }, + other: { + secret: 'abc123', + }, }, }, ]); diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 11cb1dc338..54e4ab5208 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -15,9 +15,16 @@ */ import fs from 'fs-extra'; -import { resolve as resolvePath, dirname, isAbsolute } from 'path'; -import { AppConfig, JsonObject, JsonValue } from '@backstage/config'; -import { readConfigFile, readEnvConfig, readSecret } from './lib'; +import yaml from 'yaml'; +import { resolve as resolvePath, dirname, isAbsolute, basename } from 'path'; +import { AppConfig } from '@backstage/config'; +import { + applyConfigTransforms, + readEnvConfig, + createIncludeTransform, + createSubstitutionTransform, +} from './lib'; +import { EnvFunc } from './lib/transform/types'; export type LoadConfigOptions = { // The root directory of the config loading context. Used to find default configs. @@ -26,39 +33,22 @@ export type LoadConfigOptions = { // Absolute paths to load config files from. Configs from earlier paths have lower priority. configPaths: string[]; - // TODO(Rugvip): This will be removed in the future, but for now we use it to warn about possible mistakes. - env: string; + /** @deprecated This option has been removed */ + env?: string; + + /** + * Custom environment variable loading function + * + * @experimental This API is not stable and may change at any point + */ + experimentalEnvFunc?: EnvFunc; }; -class Context { - constructor( - private readonly options: { - env: { [name in string]?: string }; - rootPath: string; - }, - ) {} - - get env() { - return this.options.env; - } - - async readFile(path: string): Promise { - return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8'); - } - - async readSecret( - _path: string, - desc: JsonObject, - ): Promise { - return readSecret(desc, this); - } -} - export async function loadConfig( options: LoadConfigOptions, ): Promise { const configs = []; - const { configRoot } = options; + const { configRoot, experimentalEnvFunc: envFunc } = options; const configPaths = options.configPaths.slice(); // If no paths are provided, we default to reading @@ -70,36 +60,31 @@ export async function loadConfig( if (await fs.pathExists(localConfig)) { configPaths.push(localConfig); } - - const envFile = `app-config.${options.env}.yaml`; - if (await fs.pathExists(resolvePath(configRoot, envFile))) { - console.error( - `Env config file '${envFile}' is not loaded as APP_ENV and NODE_ENV-based config loading has been removed`, - ); - console.error( - `To load the config file, use --config , listing every config file that you want to load`, - ); - } } + const env = envFunc ?? (async (name: string) => process.env[name]); + try { for (const configPath of configPaths) { if (!isAbsolute(configPath)) { throw new Error(`Config load path is not absolute: '${configPath}'`); } - const config = await readConfigFile( - configPath, - new Context({ - env: process.env, - rootPath: dirname(configPath), - }), - ); - configs.push(config); + const dir = dirname(configPath); + const readFile = (path: string) => + fs.readFile(resolvePath(dir, path), 'utf8'); + + const input = yaml.parse(await readFile(configPath)); + const data = await applyConfigTransforms(dir, input, [ + createIncludeTransform(env, readFile), + createSubstitutionTransform(env), + ]); + + configs.push({ data, context: basename(configPath) }); } } catch (error) { throw new Error( - `Failed to read static configuration file: ${error.message}`, + `Failed to read static configuration file, ${error.message}`, ); } diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index 3f412f3e17..a40e25b2a8 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/core-api +## 0.2.9 + +### Patch Changes + +- ab0892358: Remove test dependencies from production package list + ## 0.2.8 ### Patch Changes diff --git a/packages/core-api/package.json b/packages/core-api/package.json index a818d38143..927eca3490 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.2.8", + "version": "0.2.9", "private": false, "publishConfig": { "access": "public", @@ -30,8 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.2", - "@backstage/test-utils": "^0.1.6", - "@backstage/theme": "^0.2.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", @@ -43,7 +42,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@backstage/cli": "^0.6.0", + "@backstage/test-utils": "^0.1.6", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/core-api/src/apis/definitions/ErrorApi.ts b/packages/core-api/src/apis/definitions/ErrorApi.ts index dfa08130cd..6205f8e058 100644 --- a/packages/core-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-api/src/apis/definitions/ErrorApi.ts @@ -18,7 +18,7 @@ import { ApiRef, createApiRef } from '../system'; import { Observable } from '../../types'; /** - * Mirrors the javascript Error class, for the purpose of + * Mirrors the JavaScript Error class, for the purpose of * providing documentation and optional fields. */ type Error = { diff --git a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts index 517bf82ae7..e7764fcbaa 100644 --- a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -13,12 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - AuthProvider, - ProfileInfo, - BackstageIdentity, - DiscoveryApi, -} from '../../apis/definitions'; +import { AuthProvider, DiscoveryApi } from '../../apis/definitions'; import { showLoginPopup } from '../loginPopup'; type Options = { @@ -26,13 +21,6 @@ type Options = { 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; diff --git a/packages/core-api/src/lib/loginPopup.ts b/packages/core-api/src/lib/loginPopup.ts index 2e14447882..716c4f9651 100644 --- a/packages/core-api/src/lib/loginPopup.ts +++ b/packages/core-api/src/lib/loginPopup.ts @@ -82,7 +82,9 @@ export function showLoginPopup(options: LoginPopupOptions): Promise { let targetOrigin = ''; if (!popup || typeof popup.closed === 'undefined' || popup.closed) { - reject(new Error('Failed to open auth popup.')); + const error = new Error('Failed to open auth popup.'); + error.name = 'PopupRejectedError'; + reject(error); return; } @@ -120,7 +122,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise { const intervalId = setInterval(() => { if (popup.closed) { const errMessage = `Login failed, ${ - targetOrigin !== window.location.origin + targetOrigin && targetOrigin !== window.location.origin ? `Incorrect app origin, expected ${targetOrigin}` : 'popup was closed' }`; diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx index dd6d7960ac..cc168707be 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -68,9 +68,6 @@ export class PluginImpl< options, }); }, - registerRoute(path, component, options) { - outputs.push({ type: 'legacy-route', path, component, options }); - }, }, featureFlags: { register(name) { diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index 94bd4e8172..711246f1f4 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -99,26 +99,22 @@ export type PluginConfig< }; export type PluginHooks = { + /** + * @deprecated All router hooks have been deprecated + */ router: RouterHooks; featureFlags: FeatureFlagsHooks; }; export type RouterHooks = { + /** + * @deprecated Use a routable extension instead, see https://backstage.io/docs/plugins/composability#porting-existing-plugins + */ addRoute( target: RouteRef, Component: ComponentType, options?: RouteOptions, ): void; - - /** - * @deprecated See the `addRoute` method - * @see https://github.com/backstage/backstage/issues/418 - */ - registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, - ): void; }; export type FeatureFlagsHooks = { diff --git a/packages/core-api/src/routing/FlatRoutes.tsx b/packages/core-api/src/routing/FlatRoutes.tsx index a6783964a7..cefb347290 100644 --- a/packages/core-api/src/routing/FlatRoutes.tsx +++ b/packages/core-api/src/routing/FlatRoutes.tsx @@ -25,8 +25,8 @@ type RouteObject = { // Similar to the same function from react-router, this collects routes from the // children, but only the first level of routes -function createRoutesFromChildren(children: ReactNode): RouteObject[] { - return Children.toArray(children) +function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { + return Children.toArray(childrenNode) .flatMap(child => { if (!isValidElement(child)) { return []; diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 4c2da34098..0a3df49b58 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,7 +14,16 @@ * limitations under the License. */ -import { RouteRefConfig, RouteRef } from './types'; +import { RouteRef } from './types'; +import { IconComponent } from '../icons'; + +export type RouteRefConfig = { + params?: Array; + /** @deprecated Route refs no longer decide their own path */ + path?: string; + icon?: IconComponent; + title: string; +}; export class AbsoluteRouteRef { constructor(private readonly config: RouteRefConfig) {} @@ -38,9 +47,20 @@ export class AbsoluteRouteRef { } export function createRouteRef< - ParamKeys extends string, - Params extends { [param in string]: string } = { [name in ParamKeys]: string } ->(config: RouteRefConfig): RouteRef { + // Params is the type that we care about and the one to be embedded in the route ref. + // For example, given the params ['name', 'kind'], Params will be {name: string, kind: string} + Params extends { [param in ParamKey]: string }, + // ParamKey is here to make sure the Params type properly has its keys narrowed down + // to only the elements of params. Defaulting to never makes sure we end up with + // Param = {} if the params array is empty. + ParamKey extends string = never +>(config: { + params?: ParamKey[]; + /** @deprecated Route refs no longer decide their own path */ + path?: string; + icon?: IconComponent; + title: string; +}): RouteRef { return new AbsoluteRouteRef(config); } diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 3dbeaf8e3e..84a8314807 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -39,8 +39,9 @@ import { createRouteRef, createExternalRouteRef, ExternalRouteRef, + RouteRefConfig, } from './RouteRef'; -import { RouteRef, RouteRefConfig } from './types'; +import { RouteRef } from './types'; const mockConfig = (extra?: Partial>) => ({ path: '/unused', diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 9564b3b225..af71e0c3e9 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -16,11 +16,11 @@ export type { RouteRef, - RouteRefConfig, AbsoluteRouteRef, ConcreteRoute, MutableRouteRef, } from './types'; export { FlatRoutes } from './FlatRoutes'; export { createRouteRef } from './RouteRef'; +export type { RouteRefConfig } from './RouteRef'; export { useRouteRef } from './hooks'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 99a20c31e9..5f9376c1d2 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -45,14 +45,6 @@ export type AbsoluteRouteRef = RouteRef<{}>; */ export type MutableRouteRef = RouteRef<{}>; -export type RouteRefConfig = { - params?: Array; - /** @deprecated Route refs no longer decide their own path */ - path?: string; - icon?: IconComponent; - title: string; -}; - // A duplicate of the react-router RouteObject, but with routeRef added export interface BackstageRouteObject { caseSensitive: boolean; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index bb13447f94..60a6e43654 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,101 @@ # @backstage/core +## 0.6.0 + +### Minor Changes + +- 21e624ba9: Closes #3556 + The scroll bar of collapsed sidebar is now hidden without full screen. + + ![image](https://user-images.githubusercontent.com/46953622/105390193-0bfd0080-5c19-11eb-8e86-2161bbe6e8d9.png) + +### Patch Changes + +- 12ece98cd: Add className to the SidebarItem +- d82246867: Update `WarningPanel` component to use accordion-style expansion +- 5fa3bdb55: Add `href` in addition to `onClick` to `ItemCard`. Ensure that the height of a + `ItemCard` with and without tags is equal. +- da9f53c60: Add a `prop` union for `SignInPage` that allows it to be used for just a single provider, with inline errors, and optionally with automatic sign-in. +- 32c95605f: Fix check that determines whether popup was closed or the messaging was misconfigured. +- 54c7d02f7: Introduce `TabbedLayout` for creating tabs that are routed. + + ```typescript + + +
This is rendered under /example/anything-here route
+
+
+ ``` + +- Updated dependencies [c810082ae] + - @backstage/theme@0.2.3 + +## 0.5.0 + +### Minor Changes + +- efd6ef753: Removed `InfoCard` variant `height100`, originally deprecated in [#2826](https://github.com/backstage/backstage/pull/2826). + + If your component still relies on this variant, simply replace it with `gridItem`. + +- a187b8ad0: Removed deprecated `router.registerRoute` method in `createPlugin`. + + Deprecated `router.addRoute` method in `createPlugin`. + + Replace usage of the above two components with a routable extension. + + For example, given the following: + + ```ts + import { createPlugin } from '@backstage/core'; + import { MyPage } from './components/MyPage'; + import { rootRoute } from './routes'; + + export const plugin = createPlugin({ + id: 'my-plugin', + register({ router }) { + router.addRoute(rootRoute, MyPage); + }, + }); + ``` + + Migrate to + + ```ts + import { createPlugin, createRoutableExtension } from '@backstage/core'; + import { rootRoute } from './routes'; + + export const plugin = createPlugin({ + id: 'my-plugin', + routes: { + root: rootRoute, + }, + }); + + export const MyPage = plugin.provide( + createRoutableExtension({ + component: () => import('./components/MyPage').then(m => m.MyPage), + mountPoint: rootRoute, + }), + ); + ``` + + And then use `MyPage` like this in the app: + + ```tsx + + ... + }> + ... + + ``` + +## 0.4.4 + +### Patch Changes + +- 265a7ab30: Fix issue where `SidebarItem` with `onClick` and without `to` renders an inaccessible div. It now renders a button. + ## 0.4.3 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index b84eb4ff7b..41dbc8ef14 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.4.3", + "version": "0.6.0", "private": false, "publishConfig": { "access": "public", @@ -31,10 +31,11 @@ "dependencies": { "@backstage/config": "^0.1.2", "@backstage/core-api": "^0.2.8", - "@backstage/theme": "^0.2.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@testing-library/react-hooks": "^3.4.2", "@types/dagre": "^0.7.44", "@types/react": "^16.9", "@types/react-sparklines": "^1.7.0", @@ -46,7 +47,7 @@ "d3-zoom": "^2.0.0", "dagre": "^0.8.5", "qs": "^6.9.4", - "immer": "^7.0.9", + "immer": "^8.0.1", "lodash": "^4.17.15", "material-table": "^1.69.1", "prop-types": "^15.7.2", @@ -65,7 +66,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@backstage/cli": "^0.6.0", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/core/src/components/Button/Button.stories.tsx b/packages/core/src/components/Button/Button.stories.tsx index a5005574fe..af95e64d52 100644 --- a/packages/core/src/components/Button/Button.stories.tsx +++ b/packages/core/src/components/Button/Button.stories.tsx @@ -15,8 +15,17 @@ */ import React, { FunctionComponentFactory } from 'react'; import { Button } from './Button'; -import { MemoryRouter, Route, useLocation } from 'react-router-dom'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { createRouteRef } from '@backstage/core-api'; +import { + Divider, + Link, + List, + ListItem, + ListItemText, + Typography, + Button as MaterialButton, +} from '@material-ui/core'; const Location = () => { const location = useLocation(); @@ -28,14 +37,27 @@ export default { component: Button, decorators: [ (storyFn: FunctionComponentFactory<{}>) => ( - -
+ <> + + A collection of buttons that should be used in the Backstage + interface. These leverage the properties inherited from{' '} + + Material-UI Button + + , but include an opinionated set that align to the Backstage design. + + + + +
- +
+ +
+ {storyFn()}
- {storyFn()} -
-
+ + ), ], }; @@ -46,36 +68,107 @@ export const Default = () => { title: 'Hi there!', }); + // Design Permutations: + // color = default | primary | secondary + // variant = contained | outlined | text return ( - <> -  will utilise the - react-router MemoryRouter's navigation - -

{routeRef.title}

-
- + + + + Default Button: + This is the default button design which should be used in most cases. +
+
color="primary" variant="contained"
+
+ + +
+ + + Secondary Button: + Used for actions that cancel, skip, and in general perform negative + functions, etc. +
+
color="secondary" variant="contained"
+
+ + +
+ + + Tertiary Button: + Used commonly in a ButtonGroup and when the button function itself is + not a primary function on a page. +
+
color="default" variant="outlined"
+
+ + +
+
); }; -export const PassProps = () => { +export const ButtonLinks = () => { const routeRef = createRouteRef({ path: '/hello', title: 'Hi there!', }); + const handleClick = () => { + return 'Your click worked!'; + }; + return ( <> - -  has props for both material-ui's component as well as for - react-router-dom's - -

{routeRef.title}

-
+ + { + // TODO: Refactor to use new routing mechanisms + } + + +   has props for both Material-UI's component as well as for + react-router-dom's Route object. + + + + +   links to a statically defined route. In general, this should be + avoided. + + + + + View URL + +   links to a defined URL using Material-UI's Button. + + + + + Trigger Event + +   triggers an onClick event using Material-UI's Button. + + ); }; -PassProps.story = { - name: `Accepts material-ui Button's and react-router-dom Link's props`, -}; diff --git a/packages/core/src/components/ProgressBars/Gauge.stories.tsx b/packages/core/src/components/ProgressBars/Gauge.stories.tsx index 7882714529..ab9c263f05 100644 --- a/packages/core/src/components/ProgressBars/Gauge.stories.tsx +++ b/packages/core/src/components/ProgressBars/Gauge.stories.tsx @@ -20,7 +20,7 @@ import { Gauge } from './Gauge'; const containerStyle = { width: 300 }; export default { - title: 'Feedback/Gauge', + title: 'Data Display/Gauge', component: Gauge, }; diff --git a/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx index a745d210b4..fa3c7c00f0 100644 --- a/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx +++ b/packages/core/src/components/ProgressBars/LinearGauge.stories.tsx @@ -20,7 +20,7 @@ import { LinearGauge } from './LinearGauge'; const containerStyle = { width: 300 }; export default { - title: 'Feedback/LinearGauge', + title: 'Data Display/LinearGauge', component: LinearGauge, }; diff --git a/packages/core/src/components/Status/Status.stories.tsx b/packages/core/src/components/Status/Status.stories.tsx index 560d07ad87..205645e1ec 100644 --- a/packages/core/src/components/Status/Status.stories.tsx +++ b/packages/core/src/components/Status/Status.stories.tsx @@ -27,7 +27,7 @@ import { Table } from '../Table'; import { InfoCard } from '../../layout/InfoCard'; export default { - title: 'Feedback/Status', + title: 'Data Display/Status', component: StatusOK, }; diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx index 4868af54bd..916259042c 100644 --- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx +++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx @@ -40,6 +40,21 @@ const nestedListStyle = (theme: Theme) => }, }); +interface StyleProps extends WithStyles { + children?: React.ReactNode; +} +// Sub Components +const StyledList = withStyles( + listStyle, +)(({ classes, children }: StyleProps) => ( + {children} +)); +const StyledNestedList = withStyles( + nestedListStyle, +)(({ classes, children }: StyleProps) => ( + {children} +)); + function renderList(list: Array, nested?: boolean) { const values = list.map((item: any, index: number) => ( {toValue(item)} @@ -100,30 +115,10 @@ function toValue( return {value}; } - -function mapToItems(info: { [key: string]: string }, options: any) { - return Object.keys(info).map(key => ( - - )); -} - -interface StyleProps extends WithStyles { - children?: React.ReactNode; -} -// Sub Components -const StyledList = withStyles( - listStyle, -)(({ classes, children }: StyleProps) => ( - {children} -)); -const StyledNestedList = withStyles( - nestedListStyle, -)(({ classes, children }: StyleProps) => ( - {children} -)); const ItemValue = ({ value, options }: { value: any; options: any }) => ( {toValue(value, options)} ); + const TableItem = ({ title, value, @@ -146,6 +141,12 @@ const TableItem = ({ ); }; +function mapToItems(info: { [key: string]: string }, options: any) { + return Object.keys(info).map(key => ( + + )); +} + type Props = { metadata: { [key: string]: any }; dense?: boolean; diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx similarity index 92% rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx rename to packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx index 065265cea4..549aa55eff 100644 --- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { TabbedLayout } from './TabbedLayout'; import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent } from '@testing-library/react'; +import React from 'react'; import { act } from 'react-dom/test-utils'; -import { Routes, Route } from 'react-router'; +import { Route, Routes } from 'react-router'; +import { RoutedTabs } from './RoutedTabs'; const testRoute1 = { path: '', @@ -31,10 +31,10 @@ const testRoute2 = { children:
tabbed-test-content-2
, }; -describe('TabbedLayout', () => { +describe('RoutedTabs', () => { it('renders simplest case', async () => { const rendered = await renderInTestApp( - , + , ); expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); @@ -46,7 +46,7 @@ describe('TabbedLayout', () => { } + element={} /> , ); @@ -70,7 +70,7 @@ describe('TabbedLayout', () => { { it('shows only one tab contents at a time', async () => { const rendered = await renderInTestApp( - , + , { routeEntries: ['/some-other-path'] }, ); @@ -135,7 +135,7 @@ describe('TabbedLayout', () => { it('redirects to the top level when no route is matching the url', async () => { const rendered = await renderInTestApp( - , + , { routeEntries: ['/non-existing-path'] }, ); diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx similarity index 84% rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx rename to packages/core/src/components/TabbedLayout/RoutedTabs.tsx index a2209ba492..b17c8afb3b 100644 --- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ import React, { useMemo } from 'react'; -import { useParams, useNavigate, matchRoutes, useRoutes } from 'react-router'; -import { HeaderTabs, Content as LayoutContent } from '@backstage/core'; import { Helmet } from 'react-helmet'; +import { matchRoutes, useNavigate, useParams, useRoutes } from 'react-router'; +import { Content, HeaderTabs } from '../../layout'; import { SubRoute } from './types'; export function useSelectedSubRoute( @@ -44,7 +44,7 @@ export function useSelectedSubRoute( }; } -export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => { +export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => { const navigate = useNavigate(); const { index, route, element } = useSelectedSubRoute(routes); const headerTabs = useMemo( @@ -52,12 +52,12 @@ export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => { [routes], ); - const onTabChange = (index: number) => + const onTabChange = (tabIndex: number) => // Remove trailing /* // And remove leading / for relative navigation // Note! route resolves relative to the position in the React tree, // not relative to current location - navigate(routes[index].path.replace(/\/\*$/, '').replace(/^\//, '')); + navigate(routes[tabIndex].path.replace(/\/\*$/, '').replace(/^\//, '')); return ( <> @@ -66,10 +66,10 @@ export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => { selectedIndex={index} onChange={onTabChange} /> - + {element} - + ); }; diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx new file mode 100644 index 0000000000..bf6175a9a2 --- /dev/null +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { PropsWithChildren } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { TabbedLayout } from './TabbedLayout'; + +export default { + title: 'Navigation/TabbedLayout', + component: TabbedLayout, +}; + +const Wrapper = ({ children }: PropsWithChildren<{}>) => ( + + + {children}} /> + + +); + +export const Default = () => ( + + + +
tabbed-test-content
+
+ +
tabbed-test-content-2
+
+
+
+); diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx new file mode 100644 index 0000000000..77230ab6cd --- /dev/null +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx @@ -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 { renderInTestApp, withLogCollector } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Route, Routes } from 'react-router'; +import { TabbedLayout } from './TabbedLayout'; + +describe('TabbedLayout', () => { + it('renders simplest case', async () => { + const { getByText } = await renderInTestApp( + + +
tabbed-test-content
+
+
, + ); + + expect(getByText('tabbed-test-title')).toBeInTheDocument(); + expect(getByText('tabbed-test-content')).toBeInTheDocument(); + }); + + it('throws if any other component is a child of TabbedLayout', async () => { + const { error } = await withLogCollector(async () => { + await expect( + renderInTestApp( + + +
tabbed-test-content
+
+
This will cause app to throw
+
, + ), + ).rejects.toThrow(/Child of TabbedLayout must be an TabbedLayout.Route/); + }); + + expect(error).toEqual([ + expect.stringMatching( + /Child of TabbedLayout must be an TabbedLayout.Route/, + ), + expect.stringMatching( + /The above error occurred in the component/, + ), + ]); + }); + + it('navigates when user clicks different tab', async () => { + const { getByText, queryByText, queryAllByRole } = await renderInTestApp( + + + +
tabbed-test-content
+
+ +
tabbed-test-content-2
+
+
+ } + /> + , + ); + + const secondTab = queryAllByRole('tab')[1]; + act(() => { + fireEvent.click(secondTab); + }); + + expect(getByText('tabbed-test-title')).toBeInTheDocument(); + expect(queryByText('tabbed-test-content')).not.toBeInTheDocument(); + + expect(getByText('tabbed-test-title-2')).toBeInTheDocument(); + expect(queryByText('tabbed-test-content-2')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx new file mode 100644 index 0000000000..f181ee5980 --- /dev/null +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx @@ -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 { attachComponentData } from '@backstage/core-api'; +import React, { + Children, + Fragment, + isValidElement, + PropsWithChildren, + ReactNode, +} from 'react'; +import { RoutedTabs } from './RoutedTabs'; + +type SubRoute = { + path: string; + title: string; + children: JSX.Element; +}; + +const Route: (props: SubRoute) => null = () => null; + +// This causes all mount points that are discovered within this route to use the path of the route itself +attachComponentData(Route, 'core.gatherMountPoints', true); + +export function createSubRoutesFromChildren( + childrenProps: ReactNode, +): SubRoute[] { + // Directly comparing child.type with Route will not work with in + // combination with react-hot-loader in storybook + // https://github.com/gaearon/react-hot-loader/issues/304 + const routeType = ( + +
+ + ).type; + + return Children.toArray(childrenProps).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + if (child.type === Fragment) { + return createSubRoutesFromChildren(child.props.children); + } + + if (child.type !== routeType) { + throw new Error('Child of TabbedLayout must be an TabbedLayout.Route'); + } + + const { path, title, children } = child.props; + return [{ path, title, children }]; + }); +} + +/** + * TabbedLayout is a compound component, which allows you to define a layout for + * pages using a sub-navigation mechanism. + * + * Consists of two parts: TabbedLayout and TabbedLayout.Route + * + * @example + * ```jsx + * + * + *
This is rendered under /example/anything-here route
+ *
+ *
+ * ``` + */ +export const TabbedLayout = ({ children }: PropsWithChildren<{}>) => { + const routes = createSubRoutesFromChildren(children); + + return ; +}; + +TabbedLayout.Route = Route; diff --git a/plugins/kubernetes/src/components/ConfigMaps/index.ts b/packages/core/src/components/TabbedLayout/index.ts similarity index 92% rename from plugins/kubernetes/src/components/ConfigMaps/index.ts rename to packages/core/src/components/TabbedLayout/index.ts index b1c7d37a5a..744b56959e 100644 --- a/plugins/kubernetes/src/components/ConfigMaps/index.ts +++ b/packages/core/src/components/TabbedLayout/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { ConfigMaps } from './ConfigMaps'; +export { TabbedLayout } from './TabbedLayout'; diff --git a/plugins/catalog/src/components/EntityLayout/types.ts b/packages/core/src/components/TabbedLayout/types.ts similarity index 100% rename from plugins/catalog/src/components/EntityLayout/types.ts rename to packages/core/src/components/TabbedLayout/types.ts diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index 852f4a1547..dbb2964845 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -40,6 +40,7 @@ import ViewColumn from '@material-ui/icons/ViewColumn'; import { isEqual, transform } from 'lodash'; import MTable, { Column, + Icons, MaterialTableProps, MTableHeader, MTableToolbar, @@ -56,58 +57,28 @@ import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree'; import { SelectProps } from '../Select/Select'; import { Filter, Filters, SelectedFilters, Without } from './Filters'; -const tableIcons = { - Add: forwardRef((props, ref: React.Ref) => ( - - )), - Check: forwardRef((props, ref: React.Ref) => ( - - )), - Clear: forwardRef((props, ref: React.Ref) => ( - - )), - Delete: forwardRef((props, ref: React.Ref) => ( - - )), - DetailPanel: forwardRef((props, ref: React.Ref) => ( +const tableIcons: Icons = { + Add: forwardRef((props, ref) => ), + Check: forwardRef((props, ref) => ), + Clear: forwardRef((props, ref) => ), + Delete: forwardRef((props, ref) => ), + DetailPanel: forwardRef((props, ref) => ( )), - Edit: forwardRef((props, ref: React.Ref) => ( - - )), - Export: forwardRef((props, ref: React.Ref) => ( - - )), - Filter: forwardRef((props, ref: React.Ref) => ( - - )), - FirstPage: forwardRef((props, ref: React.Ref) => ( - - )), - LastPage: forwardRef((props, ref: React.Ref) => ( - - )), - NextPage: forwardRef((props, ref: React.Ref) => ( - - )), - PreviousPage: forwardRef((props, ref: React.Ref) => ( + Edit: forwardRef((props, ref) => ), + Export: forwardRef((props, ref) => ), + Filter: forwardRef((props, ref) => ), + FirstPage: forwardRef((props, ref) => ), + LastPage: forwardRef((props, ref) => ), + NextPage: forwardRef((props, ref) => ), + PreviousPage: forwardRef((props, ref) => ( )), - ResetSearch: forwardRef((props, ref: React.Ref) => ( - - )), - Search: forwardRef((props, ref: React.Ref) => ( - - )), - SortArrow: forwardRef((props, ref: React.Ref) => ( - - )), - ThirdStateCheck: forwardRef((props, ref: React.Ref) => ( - - )), - ViewColumn: forwardRef((props, ref: React.Ref) => ( - - )), + ResetSearch: forwardRef((props, ref) => ), + Search: forwardRef((props, ref) => ), + SortArrow: forwardRef((props, ref) => ), + ThirdStateCheck: forwardRef((props, ref) => ), + ViewColumn: forwardRef((props, ref) => ), }; // TODO: Material table might already have such a function internally that we can use? diff --git a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx index 5098f0aa31..ef99a0fce4 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { WarningPanel } from './WarningPanel'; -import { Link, Button } from '@material-ui/core'; +import { Button, Link, Typography } from '@material-ui/core'; export default { title: 'Feedback/Warning Panel', @@ -25,11 +25,11 @@ export default { export const Default = () => ( - This example entity is missing something. If this is unexpected, please - make sure you have set up everything correctly by following{' '} + This example entity is missing an annotation. If this is unexpected, + please make sure you have set up everything correctly by following{' '} this guide. } @@ -37,9 +37,36 @@ export const Default = () => ( ); export const Children = () => ( - - + + + Supports custom children - for example these text elements. This can be + used to hide/expose stack traces for warnings, like this example: +
+ SyntaxError: Error transforming + /home/user/github/backstage/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx: + Unexpected token (42:16) at unexpected + (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/util.js:83:15) + at tsParseMaybeAssignWithJSX + (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1399:22) + at tsParseMaybeAssign + (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1373:12) + at parseMaybeAssign + (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:118:43) + at parseExprListItem + (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:969:5) +
+
); + +export const FullExample = () => ( + + HTTP 500 Bad Gateway response from + https://usefulservice.mycompany.com/api/entity?44433 + +); + +export const TitleOnly = () => ; diff --git a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx index 07a25d34c8..38ba4bff9b 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx @@ -15,23 +15,53 @@ */ import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; import { renderInTestApp } from '@backstage/test-utils'; +import { Typography } from '@material-ui/core'; import { WarningPanel } from './WarningPanel'; -const minProps = { title: 'Mock title', message: 'Some more info' }; +const propsTitle = { title: 'Mock title' }; +const propsTitleMessage = { title: 'Mock title', message: 'Some more info' }; +const propsMessage = { message: 'Some more info' }; describe('', () => { it('renders without exploding', async () => { - const { getByText } = await renderInTestApp(); - expect(getByText('Mock title')).toBeInTheDocument(); + await renderInTestApp(); + expect(screen.getByText('Warning: Mock title')).toBeInTheDocument(); }); - it('renders message and children', async () => { - const { getByText } = await renderInTestApp( - children, + it('renders title', async () => { + await renderInTestApp(); + const expandIcon = await screen.getByText('Warning: Mock title'); + fireEvent.click(expandIcon); + expect(screen.getByText('Warning: Mock title')).toBeInTheDocument(); + expect(screen.getByText('Some more info')).toBeInTheDocument(); + }); + + it('renders title and children', async () => { + await renderInTestApp( + + Java stacktrace + , ); - expect(getByText('Some more info')).toBeInTheDocument(); - expect(getByText('children')).toBeInTheDocument(); + expect(screen.getByText('Java stacktrace')).toBeInTheDocument(); + }); + + it('renders message', async () => { + await renderInTestApp(); + expect(screen.getByText('Warning')).toBeInTheDocument(); + expect(screen.getByText('Some more info')).toBeInTheDocument(); + }); + + it('renders title, message, and children', async () => { + await renderInTestApp( + + Java stacktrace + , + ); + expect(screen.getByText('Warning: Mock title')).toBeInTheDocument(); + expect(screen.getByText('Some more info')).toBeInTheDocument(); + expect(screen.getByText('Java stacktrace')).toBeInTheDocument(); }); }); diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx index ae4d2bff02..e82c49c49a 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -15,8 +15,16 @@ */ import { BackstageTheme } from '@backstage/theme'; -import { makeStyles, Typography } from '@material-ui/core'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Grid, + makeStyles, + Typography, +} from '@material-ui/core'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import React from 'react'; const useErrorOutlineStyles = makeStyles(theme => ({ @@ -29,57 +37,100 @@ const ErrorOutlineStyled = () => { const classes = useErrorOutlineStyles(); return ; }; +const ExpandMoreIconStyled = () => { + const classes = useErrorOutlineStyles(); + return ; +}; const useStyles = makeStyles(theme => ({ - message: { - display: 'flex', - flexDirection: 'column', - padding: theme.spacing(1.5), + panel: { backgroundColor: theme.palette.warningBackground, color: theme.palette.warningText, verticalAlign: 'middle', }, - header: { + summary: { display: 'flex', flexDirection: 'row', - marginBottom: theme.spacing(1), }, - headerText: { + summaryText: { color: theme.palette.warningText, + fontWeight: 'bold', }, - messageText: { + message: { + width: '100%', + display: 'block', color: theme.palette.warningText, + backgroundColor: theme.palette.warningBackground, + }, + details: { + width: '100%', + display: 'block', + color: theme.palette.textContrast, + backgroundColor: theme.palette.background.default, + border: `1px solid ${theme.palette.border}`, + padding: theme.spacing(2.0), + fontFamily: 'sans-serif', }, })); -/** - * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel - * only shows the warning message to the user - */ - type Props = { - message?: React.ReactNode; title?: string; + severity?: 'warning' | 'error' | 'info'; + message?: React.ReactNode; children?: React.ReactNode; }; +const capitalize = (s: string) => { + return s.charAt(0).toUpperCase() + s.slice(1); +}; + +/** + * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel + * only shows the warning message to the user. + * + * @param {string} [severity=warning] Ability to change the severity of the alert. Not fully implemented. (error, warning, info) + * @param {string} [title] A title for the warning. If not supplied, "Warning" will be used. + * @param {Object} [message] Optional more detailed user-friendly message elaborating on the cause of the error. + * @param {Object} [children] Objects to provide context, such as a stack trace or detailed error reporting. + * Will be available inside an unfolded accordion. + */ export const WarningPanel = (props: Props) => { const classes = useStyles(props); - const { title, message, children } = props; + const { severity, title, message, children } = props; + + // If no severity or title provided, the heading will read simply "Warning" + const subTitle = + (severity ? capitalize(severity) : 'Warning') + (title ? `: ${title}` : ''); + return ( -
-
+ + } + className={classes.summary} + > - - {title} - -
- {message && ( - - {message} + + {subTitle} + + {(message || children) && ( + + + {message && ( + + + {message} + + + )} + {children && ( + + {children} + + )} + + )} - {children} -
+ ); }; diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 85ebba7a58..77eba9759a 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -21,10 +21,13 @@ export * from './CodeSnippet'; export * from './CopyTextButton'; export * from './DependencyGraph'; export * from './DismissableBanner'; +export * from './EmptyState'; export * from './FeatureDiscovery'; +export * from './HeaderIconLinkRow'; export * from './HorizontalScrollGrid'; export * from './Lifecycle'; export * from './Link'; +export * from './MarkdownContent'; export * from './OAuthRequestDialog'; export * from './Progress'; export * from './ProgressBars'; @@ -32,10 +35,8 @@ export * from './SimpleStepper'; export * from './Status'; export * from './StructuredMetadataTable'; export * from './SupportButton'; +export * from './TabbedLayout'; export * from './Table'; export * from './Tabs'; export * from './TrendLine'; export * from './WarningPanel'; -export * from './EmptyState'; -export * from './MarkdownContent'; -export * from './HeaderIconLinkRow'; diff --git a/packages/core/src/layout/ErrorBoundary/ErrorBoundary.tsx b/packages/core/src/layout/ErrorBoundary/ErrorBoundary.tsx index 17900ad8e3..88784dc8b0 100644 --- a/packages/core/src/layout/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/core/src/layout/ErrorBoundary/ErrorBoundary.tsx @@ -26,6 +26,21 @@ type State = { errorInfo?: ErrorInfo; }; +type EProps = { + error?: Error; + slackChannel?: string; + children?: React.ReactNode; +}; + +const Error = ({ slackChannel }: EProps) => { + return ( +
+ Something went wrong here.{' '} + {slackChannel && <>Please contact {slackChannel} for help.} +
+ ); +}; + export const ErrorBoundary: ComponentClass< Props, State @@ -56,18 +71,3 @@ export const ErrorBoundary: ComponentClass< return ; } }; - -type EProps = { - error?: Error; - slackChannel?: string; - children?: React.ReactNode; -}; - -const Error = ({ slackChannel }: EProps) => { - return ( -
- Something went wrong here.{' '} - {slackChannel && <>Please contact {slackChannel} for help.} -
- ); -}; diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx index a1b4e1a97f..718a103f00 100644 --- a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx @@ -18,7 +18,7 @@ // This is just a temporary solution to implementing tabs for now import React, { useState, useEffect } from 'react'; -import { makeStyles, Tabs, Tab } from '@material-ui/core'; +import { makeStyles, Tabs, Tab as TabUI } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ tabsWrapper: { @@ -81,7 +81,7 @@ export const HeaderTabs = ({ value={selectedTab} > {tabs.map((tab, index) => ( - { + const configApi: ConfigApi = new ConfigReader({ + homepage: { + clocks: [ + { + label: 'New York', + timezone: 'America/New_Pork', + }, + ], + }, + context: 'test', + }); + + const rendered = await renderWithEffects( + + + + + , + ); + + expect(rendered.getByText('GMT')).toBeInTheDocument(); +}); diff --git a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx index 59b7f3fb3d..2adbf8fbd5 100644 --- a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx +++ b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx @@ -39,18 +39,28 @@ function getTimes(configApi: ConfigApi) { for (const clock of clockConfigs) { if (clock.has('label') && clock.has('timezone')) { + let label = clock.getString('label'); + const options = { timeZone: clock.getString('timezone'), ...timeFormat, }; - const time = d.toLocaleTimeString(lang, options); - const label = clock.getString('label'); + try { + new Date().toLocaleString(lang, options); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `The timezone ${options.timeZone} is invalid. Defaulting to GMT`, + ); + options.timeZone = 'GMT'; + label = 'GMT'; + } + const time = d.toLocaleTimeString(lang, options); clocks.push({ time, label }); } } - return clocks; } diff --git a/packages/core/src/layout/InfoCard/InfoCard.tsx b/packages/core/src/layout/InfoCard/InfoCard.tsx index 5d77f4f34a..af10010d14 100644 --- a/packages/core/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core/src/layout/InfoCard/InfoCard.tsx @@ -76,26 +76,11 @@ const VARIANT_STYLES = { height: 'calc(100% - 10px)', // for pages without content header marginBottom: '10px', }, - /** - * @deprecated This variant is replaced by 'gridItem'. - */ - height100: { - display: 'flex', - flexDirection: 'column', - height: 'calc(100% - 10px)', // for pages without content header - marginBottom: '10px', - }, }, cardContent: { fullHeight: { flex: 1, }, - /** - * @deprecated This variant is replaced by 'gridItem'. - */ - height100: { - flex: 1, - }, gridItem: { flex: 1, }, @@ -167,12 +152,6 @@ export const InfoCard = ({ if (variant) { const variants = variant.split(/[\s]+/g); variants.forEach(name => { - if (name === 'height100') { - // eslint-disable-next-line no-console - console.warn( - "Variant 'height100' of InfoCard is deprecated. Use variant 'gridItem' instead.", - ); - } calculatedStyle = { ...calculatedStyle, ...VARIANT_STYLES.card[name as keyof typeof VARIANT_STYLES['card']], diff --git a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx index 75031a0765..b53fa9eb7a 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { ItemCard } from '.'; import { Grid } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { ItemCard } from '.'; export default { title: 'Layout/Item Card', @@ -63,5 +64,35 @@ export const Tags = () => ( label="Button" /> + + + ); + +export const Link = () => ( + + + + + + + + + + +); diff --git a/packages/core/src/layout/ItemCard/ItemCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx index 991a05708f..4242cd6381 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.tsx @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Button, Card, Chip, makeStyles, Typography } from '@material-ui/core'; +import clsx from 'clsx'; import React from 'react'; -import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core'; +import { Link } from '../../components'; const useStyles = makeStyles(theme => ({ header: { @@ -30,6 +32,9 @@ const useStyles = makeStyles(theme => ({ overflow: 'hidden', textOverflow: 'ellipsis', }, + withTags: { + height: 'calc(175px - 32px - 8px)', + }, footer: { display: 'flex', flexDirection: 'row-reverse', @@ -43,7 +48,9 @@ type ItemCardProps = { type?: string; label: string; onClick?: () => void; + href?: string; }; + export const ItemCard = ({ description, tags, @@ -51,6 +58,7 @@ export const ItemCard = ({ type, label, onClick, + href, }: ItemCardProps) => { const classes = useStyles(); @@ -64,13 +72,27 @@ export const ItemCard = ({ {tags?.map((tag, i) => ( ))} - + 0 && classes.withTags, + )} + > {description}
- + {!href && ( + + )} + {href && ( + + )}
diff --git a/packages/core/src/layout/Sidebar/Bar.tsx b/packages/core/src/layout/Sidebar/Bar.tsx index d7318de27d..260593de95 100644 --- a/packages/core/src/layout/Sidebar/Bar.tsx +++ b/packages/core/src/layout/Sidebar/Bar.tsx @@ -39,6 +39,8 @@ const useStyles = makeStyles(theme => ({ padding: 0, background: theme.palette.navigation.background, overflowX: 'hidden', + msOverflowStyle: 'none', + scrollbarWidth: 'none', width: sidebarConfig.drawerWidthClosed, transition: theme.transitions.create('width', { easing: theme.transitions.easing.sharp, @@ -47,6 +49,9 @@ const useStyles = makeStyles(theme => ({ '& > *': { flexShrink: 0, }, + '&::-webkit-scrollbar': { + display: 'none', + }, }, drawerOpen: { width: sidebarConfig.drawerWidthOpen, diff --git a/packages/core/src/layout/Sidebar/Items.test.tsx b/packages/core/src/layout/Sidebar/Items.test.tsx new file mode 100644 index 0000000000..fb0ee174f8 --- /dev/null +++ b/packages/core/src/layout/Sidebar/Items.test.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 { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import HomeIcon from '@material-ui/icons/Home'; +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; +import { Sidebar } from './Bar'; +import { SidebarItem } from './Items'; +import { renderHook } from '@testing-library/react-hooks'; +import { hexToRgb, makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + spotlight: { + backgroundColor: '#2b2a2a', + }, +}); + +async function renderSidebar() { + const { result } = renderHook(() => useStyles()); + + await renderInTestApp( + + + {}} + text="Create..." + className={result.current.spotlight} + /> + , + ); + userEvent.hover(screen.getByTestId('sidebar-root')); +} + +describe('Items', () => { + beforeEach(async () => { + await renderSidebar(); + }); + + describe('SidebarItem', () => { + it('should render a link when `to` prop provided', async () => { + expect( + await screen.findByRole('link', { name: /home/i }), + ).toBeInTheDocument(); + }); + + it('should render a button when `to` prop is not provided', async () => { + expect( + await screen.findByRole('button', { name: /create/i }), + ).toBeInTheDocument(); + }); + + it('should render a button with custom style', async () => { + expect( + await screen.findByRole('button', { name: /create/i }), + ).toHaveStyle(`background-color: ${hexToRgb('2b2a2a')}`); + }); + }); +}); diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index 929828cee4..57cf435651 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -53,6 +53,15 @@ const useStyles = makeStyles(theme => { height: 48, cursor: 'pointer', }, + buttonItem: { + background: 'none', + border: 'none', + width: 'auto', + margin: 0, + padding: 0, + textAlign: 'inherit', + font: 'inherit', + }, closed: { width: drawerWidthClosed, justifyContent: 'center', @@ -114,100 +123,105 @@ const useStyles = makeStyles(theme => { }; }); -type SidebarItemProps = { +type SidebarItemBaseProps = { icon: IconComponent; text?: string; - // If 'to' is set the item will act as a nav link with highlight, otherwise it's just a button - to?: string; hasNotifications?: boolean; - onClick?: (ev: React.MouseEvent) => void; children?: ReactNode; + className?: string; }; -export const SidebarItem = forwardRef( - ( - { icon: Icon, text, to, hasNotifications = false, onClick, children }, - ref, - ) => { - const classes = useStyles(); - // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component - // depend on the current location, and at least have it being optionally forced to selected. - // Still waiting on a Q answered to fine tune the implementation - const { isOpen } = useContext(SidebarContext); +type SidebarItemButtonProps = SidebarItemBaseProps & { + onClick: (ev: React.MouseEvent) => void; +}; - const itemIcon = ( - - - - ); +type SidebarItemLinkProps = SidebarItemBaseProps & { + to: string; + onClick?: (ev: React.MouseEvent) => void; +}; - const childProps = { - onClick, - className: clsx(classes.root, isOpen ? classes.open : classes.closed), - }; +type SidebarItemProps = SidebarItemButtonProps | SidebarItemLinkProps; - if (!isOpen) { - if (to === undefined) { - return ( -
- {itemIcon} -
- ); - } +function isButtonItem( + props: SidebarItemProps, +): props is SidebarItemButtonProps { + return (props as SidebarItemLinkProps).to === undefined; +} - return ( - - {itemIcon} - - ); - } +export const SidebarItem = forwardRef((props, ref) => { + const { + icon: Icon, + text, + hasNotifications = false, + onClick, + children, + className, + } = props; + const classes = useStyles(); + // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component + // depend on the current location, and at least have it being optionally forced to selected. + // Still waiting on a Q answered to fine tune the implementation + const { isOpen } = useContext(SidebarContext); - const content = ( - <> -
- {itemIcon} -
- {text && ( - - {text} - - )} -
{children}
- - ); + const itemIcon = ( + + + + ); - if (to === undefined) { - return ( -
- {content} -
- ); - } + const closedContent = itemIcon; + const openContent = ( + <> +
+ {itemIcon} +
+ {text && ( + + {text} + + )} +
{children}
+ + ); + + const content = isOpen ? openContent : closedContent; + + const childProps = { + onClick, + className: clsx( + className, + classes.root, + isOpen ? classes.open : classes.closed, + isButtonItem(props) && classes.buttonItem, + ), + }; + + if (isButtonItem(props)) { return ( - + ); - }, -); + } + + return ( + + {content} + + ); +}); type SidebarSearchFieldProps = { onSearch: (input: string) => void; diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index 83ef8fe8a8..c3e5b75bb7 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -14,30 +14,38 @@ * limitations under the License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Page } from '../Page'; import { Header } from '../Header'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; -import { Grid } from '@material-ui/core'; +import { Grid, Button, Typography } from '@material-ui/core'; import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api'; import { useSignInProviders, getSignInProviders } from './providers'; -import { IdentityProviders } from './types'; +import { IdentityProviders, SignInConfig } from './types'; import { Progress } from '../../components/Progress'; -import { useStyles } from './styles'; +import { GridItem, useStyles } from './styles'; +import { InfoCard } from '../InfoCard'; -export type Props = SignInPageProps & { +type MultiSignInPageProps = SignInPageProps & { providers: IdentityProviders; title?: string; align?: 'center' | 'left'; }; -export const SignInPage = ({ +type SingleSignInPageProps = SignInPageProps & { + provider: SignInConfig; + auto?: boolean; +}; + +export type Props = MultiSignInPageProps | SingleSignInPageProps; + +export const MultiSignInPage = ({ onResult, providers = [], title, align = 'left', -}: Props) => { +}: MultiSignInPageProps) => { const configApi = useApi(configApiRef); const classes = useStyles(); @@ -69,3 +77,86 @@ export const SignInPage = ({ ); }; + +export const SingleSignInPage = ({ + onResult, + provider, + auto, +}: SingleSignInPageProps) => { + const classes = useStyles(); + const authApi = useApi(provider.apiRef); + const configApi = useApi(configApiRef); + + const [retry, setRetry] = useState<{} | boolean | undefined>(auto); + const [error, setError] = useState(); + + useEffect(() => { + const login = async () => { + const identity = await authApi.getBackstageIdentity({ + instantPopup: true, + }); + + const profile = await authApi.getProfile(); + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => { + return authApi.getBackstageIdentity().then(i => i!.idToken); + }, + signOut: async () => { + await authApi.signOut(); + }, + }); + }; + + if (retry) { + login().catch(setError); + } + }, [onResult, authApi, retry]); + + return ( + +
+ + + + setRetry({})} + > + Sign In + + } + > + {provider.message} + {error && error.name !== 'PopupRejectedError' && ( + + {error.message} + + )} + + + + + + ); +}; + +export const SignInPage = (props: Props) => { + if ('provider' in props) { + return ; + } + + return ; +}; diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 94260dcec9..13b854972a 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,418 @@ # @backstage/create-app +## 0.3.8 + +### Patch Changes + +- 019fe39a0: **BREAKING CHANGE**: The `useEntity` hook has been moved from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. + To apply this change to an existing app, add `@backstage/plugin-catalog-react` to your dependencies in `packages/app/package.json`, and update + the import inside `packages/app/src/components/catalog/EntityPage.tsx` as well as any other places you were using `useEntity` or any other functions that were moved to `@backstage/plugin-catalog-react`. +- 436ca3f62: Remove techdocs.requestUrl and techdocs.storageUrl from app-config.yaml +- Updated dependencies [ceef4dd89] +- Updated dependencies [720149854] +- Updated dependencies [c777df180] +- Updated dependencies [398e1f83e] +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [b712841d6] +- Updated dependencies [a5628df40] +- Updated dependencies [2430ee7c2] +- Updated dependencies [3149bfe63] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [bc5082a00] +- Updated dependencies [6e612ce25] +- Updated dependencies [e44925723] +- Updated dependencies [b37501a3d] +- Updated dependencies [a26668913] +- Updated dependencies [025e122c3] +- Updated dependencies [e9aab60c7] +- Updated dependencies [21e624ba9] +- Updated dependencies [19fe61c27] +- Updated dependencies [e9aab60c7] +- Updated dependencies [da9f53c60] +- Updated dependencies [a08c4b0b0] +- Updated dependencies [24e47ef1e] +- Updated dependencies [bc5082a00] +- Updated dependencies [b37501a3d] +- Updated dependencies [90c8f20b9] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [529d16d27] +- Updated dependencies [54c7d02f7] +- Updated dependencies [de98c32ed] +- Updated dependencies [806929fe2] +- Updated dependencies [019fe39a0] +- Updated dependencies [cdea0baf1] +- Updated dependencies [019fe39a0] +- Updated dependencies [11cb5ef94] + - @backstage/plugin-catalog-import@0.3.7 + - @backstage/plugin-scaffolder@0.4.2 + - @backstage/plugin-techdocs-backend@0.5.5 + - @backstage/cli@0.6.0 + - @backstage/core@0.6.0 + - @backstage/plugin-api-docs@0.4.4 + - @backstage/plugin-catalog@0.3.0 + - @backstage/theme@0.2.3 + - @backstage/plugin-lighthouse@0.2.9 + - @backstage/backend-common@0.5.2 + - @backstage/plugin-catalog-backend@0.6.0 + - @backstage/plugin-techdocs@0.5.5 + - @backstage/plugin-user-settings@0.2.5 + - @backstage/catalog-model@0.7.1 + - @backstage/plugin-scaffolder-backend@0.6.0 + - @backstage/plugin-app-backend@0.3.6 + - @backstage/plugin-tech-radar@0.3.4 + - @backstage/plugin-explore@0.2.4 + - @backstage/plugin-circleci@0.2.7 + - @backstage/plugin-github-actions@0.3.1 + - @backstage/plugin-search@0.2.7 + - @backstage/test-utils@0.1.6 + - @backstage/plugin-auth-backend@0.2.12 + - @backstage/plugin-proxy-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.7 + +## 0.3.7 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [12a56cdfe] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [0b1182346] +- Updated dependencies [d7b1d317f] +- Updated dependencies [a91aa6bf2] +- Updated dependencies [39b05b9ae] +- Updated dependencies [4eaa06057] + - @backstage/backend-common@0.5.1 + - @backstage/plugin-scaffolder-backend@0.5.2 + - @backstage/cli@0.5.0 + - @backstage/plugin-catalog@0.2.14 + - @backstage/plugin-catalog-backend@0.5.5 + - @backstage/plugin-catalog-import@0.3.6 + - @backstage/plugin-scaffolder@0.4.1 + - @backstage/plugin-auth-backend@0.2.12 + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/test-utils@0.1.6 + - @backstage/theme@0.2.2 + - @backstage/plugin-api-docs@0.4.3 + - @backstage/plugin-app-backend@0.3.5 + - @backstage/plugin-circleci@0.2.6 + - @backstage/plugin-explore@0.2.3 + - @backstage/plugin-github-actions@0.3.0 + - @backstage/plugin-lighthouse@0.2.8 + - @backstage/plugin-proxy-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.7 + - @backstage/plugin-search@0.2.6 + - @backstage/plugin-tech-radar@0.3.3 + - @backstage/plugin-techdocs@0.5.4 + - @backstage/plugin-techdocs-backend@0.5.4 + - @backstage/plugin-user-settings@0.2.4 + +## 0.3.6 + +### Patch Changes + +- d3947caf3: Fix accidental dependency on non-existent dependencies. +- Updated dependencies [a4e636c8f] +- Updated dependencies [099c5cf4f] +- Updated dependencies [0ea002378] +- Updated dependencies [a08db734c] + - @backstage/plugin-catalog@0.2.13 + - @backstage/plugin-scaffolder-backend@0.5.1 + +## 0.3.6 + +### Minor Changes + +- ed6baab66: - Deprecating the `scaffolder.${provider}.token` auth duplication and favoring `integrations.${provider}` instead. If you receive deprecation warnings your config should change like the following: + + ```yaml + scaffolder: + github: + token: + $env: GITHUB_TOKEN + visibility: public + ``` + + To something that looks like this: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + scaffolder: + github: + visibility: public + ``` + + You can also configure multiple different hosts under the `integration` config like the following: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + - host: ghe.mycompany.com + token: + $env: GITHUB_ENTERPRISE_TOKEN + ``` + + This of course is the case for all the providers respectively. + + - Adding support for cross provider scaffolding, you can now create repositories in for example Bitbucket using a template residing in GitHub. + + - Fix GitLab scaffolding so that it returns a `catalogInfoUrl` which automatically imports the project into the catalog. + + - The `Store Path` field on the `scaffolder` frontend has now changed so that you require the full URL to the desired destination repository. + + `backstage/new-repository` would become `https://github.com/backstage/new-repository` if provider was GitHub for example. + +### Patch Changes + +- a284f5bc1: Due to a package name change from `@kyma-project/asyncapi-react` to + `@asyncapi/react-component` the jest configuration in the root `package.json` + has to be updated: + + ```diff + "jest": { + "transformModules": [ + - "@kyma-project/asyncapi-react + + "@asyncapi/react-component" + ] + } + ``` + +- 89278acab: Migrate to using `FlatRoutes` from `@backstage/core` for the root app routes. + + This is the first step in migrating applications as mentioned here: https://backstage.io/docs/plugins/composability#porting-existing-apps. + + To apply this change to an existing app, switch out the `Routes` component from `react-router` to `FlatRoutes` from `@backstage/core`. + This also allows you to remove any `/*` suffixes on the route paths. For example: + + ```diff + import { + OAuthRequestDialog, + SidebarPage, + createRouteRef, + + FlatRoutes, + } from '@backstage/core'; + import { AppSidebar } from './sidebar'; + -import { Route, Routes, Navigate } from 'react-router'; + +import { Route, Navigate } from 'react-router'; + import { Router as CatalogRouter } from '@backstage/plugin-catalog'; + ... + + - + + + ... + } + /> + - } /> + + } /> + ... + } /> + - + + + + ``` + +- 26d3b24f3: fix routing and config for user-settings plugin + + To make the corresponding change in your local app, add the following in your App.tsx + + ``` + import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; + ... + } /> + ``` + + and the following to your plugins.ts: + + ``` + export { plugin as UserSettings } from '@backstage/plugin-user-settings'; + ``` + +- 92dbbcedd: Add `*-credentials.yaml` to gitignore to prevent accidental commits of sensitive credential information. + + To apply this change to an existing installation, add these lines to your `.gitignore` + + ```gitignore + # Sensitive credentials + *-credentials.yaml + ``` + +- d176671d1: use `fromConfig` for all scaffolder helpers, and use the url protocol for app-config location entries. + + To apply this change to your local installation, replace the contents of your `packages/backend/src/plugins/scaffolder.ts` with the following contents: + + ```ts + import { + CookieCutter, + createRouter, + Preparers, + Publishers, + CreateReactAppTemplater, + Templaters, + CatalogEntityClient, + } from '@backstage/plugin-scaffolder-backend'; + import { SingleHostDiscovery } from '@backstage/backend-common'; + import type { PluginEnvironment } from '../types'; + import Docker from 'dockerode'; + + export default async function createPlugin({ + logger, + config, + }: PluginEnvironment) { + const cookiecutterTemplater = new CookieCutter(); + const craTemplater = new CreateReactAppTemplater(); + const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); + templaters.register('cra', craTemplater); + + const preparers = await Preparers.fromConfig(config, { logger }); + const publishers = await Publishers.fromConfig(config, { logger }); + + const dockerClient = new Docker(); + + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + dockerClient, + entityClient, + }); + } + ``` + + This will ensure that the `scaffolder-backend` package can add handlers for the `url` protocol which is becoming the standard when registering entities in the `catalog` + +- 9d1d1138e: Ensured that versions bumps of packages used in the app template trigger a release of this package when needed. +- db05f7a35: Remove the `@types/helmet` dev dependency from the app template. This + dependency is now unused as the package `helmet` brings its own types. + + To update your existing app, simply remove the `@types/helmet` dependency from + the `package.json` of your backend package. + +- Updated dependencies [def2307f3] +- Updated dependencies [46bba09ea] +- Updated dependencies [efd6ef753] +- Updated dependencies [0b135e7e0] +- Updated dependencies [593632f07] +- Updated dependencies [2b514d532] +- Updated dependencies [318a6af9f] +- Updated dependencies [33846acfc] +- Updated dependencies [294a70cab] +- Updated dependencies [b604a9d41] +- Updated dependencies [ac7be581a] +- Updated dependencies [a187b8ad0] +- Updated dependencies [0ea032763] +- Updated dependencies [8855f61f6] +- Updated dependencies [5345a1f98] +- Updated dependencies [ed6baab66] +- Updated dependencies [ad838c02f] +- Updated dependencies [f04db53d7] +- Updated dependencies [a5e27d5c1] +- Updated dependencies [0643a3336] +- Updated dependencies [debf359b5] +- Updated dependencies [a2291d7cc] +- Updated dependencies [f9ba00a1c] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/plugin-catalog-backend@0.5.4 + - @backstage/plugin-github-actions@0.3.0 + - @backstage/core@0.5.0 + - @backstage/backend-common@0.5.0 + - @backstage/plugin-catalog@0.2.12 + - @backstage/plugin-catalog-import@0.3.5 + - @backstage/cli@0.4.7 + - @backstage/plugin-api-docs@0.4.3 + - @backstage/plugin-scaffolder@0.4.0 + - @backstage/plugin-scaffolder-backend@0.5.0 + - @backstage/plugin-techdocs@0.5.4 + - @backstage/plugin-techdocs-backend@0.5.4 + - @backstage/plugin-auth-backend@0.2.11 + - @backstage/plugin-lighthouse@0.2.8 + - @backstage/plugin-circleci@0.2.6 + - @backstage/plugin-search@0.2.6 + - @backstage/plugin-explore@0.2.3 + - @backstage/plugin-tech-radar@0.3.3 + - @backstage/plugin-user-settings@0.2.4 + - @backstage/plugin-app-backend@0.3.4 + - @backstage/plugin-proxy-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.7 + +## 0.3.5 + +### Patch Changes + +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- cc068c0d6: Bump the gitbeaker dependencies to 28.x. + + To update your own installation, go through the `package.json` files of all of + your packages, and ensure that all dependencies on `@gitbeaker/node` or + `@gitbeaker/core` are at version `^28.0.2`. Then run `yarn install` at the root + of your repo. + +## 0.3.4 + +### Patch Changes + +- 643dcec7c: noop release for create-app to force re-deploy + +## 0.3.3 + +### Patch Changes + +- bd9c6719f: Bumping the version for `create-app` so that we can use the latest versions of internal packages and rebuild the version which is passed to the package.json + +## 0.3.2 + +### Patch Changes + +- c2b52d9c5: Replace `register-component` plugin with new `catalog-import` plugin +- fc6839f13: Bump `sqlite3` to v5. + + To apply this change to an existing app, change the version of `sqlite3` in the `dependencies` of `packages/backend/package.json`: + + ```diff + "pg": "^8.3.0", + - "sqlite3": "^4.2.0", + + "sqlite3": "^5.0.0", + "winston": "^3.2.1" + ``` + + Note that the `sqlite3` dependency may not be preset if you chose to use PostgreSQL when creating the app. + +- 8d68e4cdc: Removed the Circle CI sidebar item, since the target page does not exist. + + To apply this change to an existing app, remove `"CircleCI"` sidebar item from `packages/app/src/sidebar.tsx`, and the `BuildIcon` import if it is unused. + +- 1773a5182: Removed lighthouse plugin from the default set up plugins, as it requires a separate Backend to function. + + To apply this change to an existing app, remove the following: + + 1. The `lighthouse` block from `app-config.yaml`. + 2. The `@backstage/plugin-lighthouse` dependency from `packages/app/package.json`. + 3. The `@backstage/plugin-lighthouse` re-export from `packages/app/src/plugins.ts`. + 4. The Lighthouse sidebar item from `packages/app/src/sidebar.tsx`, and the `RuleIcon` import if it is unused. + ## 0.3.1 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index b9743e2bc0..5f32a5d13d 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.3.1", + "version": "0.3.8", "private": false, "publishConfig": { "access": "public" @@ -37,39 +37,40 @@ "recursive-readdir": "^2.2.2" }, "devDependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", - "@backstage/cli": "^0.4.4", - "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.3", - "@backstage/plugin-api-docs": "^0.4.2", - "@backstage/plugin-app-backend": "^0.3.3", - "@backstage/plugin-auth-backend": "^0.2.8", - "@backstage/plugin-catalog": "^0.2.9", - "@backstage/plugin-catalog-backend": "^0.5.1", - "@backstage/plugin-catalog-import": "^0.3.2", - "@backstage/plugin-circleci": "^0.2.5", - "@backstage/plugin-explore": "^0.2.2", - "@backstage/plugin-github-actions": "^0.2.6", - "@backstage/plugin-lighthouse": "^0.2.6", - "@backstage/plugin-proxy-backend": "^0.2.3", - "@backstage/plugin-rollbar-backend": "^0.1.5", - "@backstage/plugin-scaffolder": "^0.3.5", - "@backstage/plugin-search": "^0.2.4", - "@backstage/plugin-scaffolder-backend": "^0.3.6", - "@backstage/plugin-tech-radar": "^0.3.1", - "@backstage/plugin-techdocs": "^0.5.2", - "@backstage/plugin-techdocs-backend": "^0.5.1", - "@backstage/plugin-user-settings": "^0.2.3", - "@backstage/test-utils": "^0.1.6", - "@backstage/theme": "^0.2.2", "@types/fs-extra": "^9.0.1", "@types/inquirer": "^7.3.1", - "@types/ora": "^3.2.0", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", "ts-node": "^8.6.2" }, + "peerDependencies": { + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/cli": "^0.6.0", + "@backstage/config": "^0.1.2", + "@backstage/core": "^0.6.0", + "@backstage/plugin-api-docs": "^0.4.4", + "@backstage/plugin-app-backend": "^0.3.6", + "@backstage/plugin-auth-backend": "^0.2.12", + "@backstage/plugin-catalog": "^0.3.0", + "@backstage/plugin-catalog-backend": "^0.6.0", + "@backstage/plugin-catalog-import": "^0.3.7", + "@backstage/plugin-circleci": "^0.2.7", + "@backstage/plugin-explore": "^0.2.4", + "@backstage/plugin-github-actions": "^0.3.1", + "@backstage/plugin-lighthouse": "^0.2.9", + "@backstage/plugin-proxy-backend": "^0.2.4", + "@backstage/plugin-rollbar-backend": "^0.1.7", + "@backstage/plugin-scaffolder": "^0.4.2", + "@backstage/plugin-search": "^0.2.7", + "@backstage/plugin-scaffolder-backend": "^0.6.0", + "@backstage/plugin-tech-radar": "^0.3.4", + "@backstage/plugin-techdocs": "^0.5.5", + "@backstage/plugin-techdocs-backend": "^0.5.5", + "@backstage/plugin-user-settings": "^0.2.5", + "@backstage/test-utils": "^0.1.6", + "@backstage/theme": "^0.2.3" + }, "nodemonConfig": { "watch": "./src", "exec": "bin/backstage-create-app", diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 5cbf6c77ee..e6a047921c 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -14,47 +14,50 @@ * limitations under the License. */ -/* eslint-disable import/no-extraneous-dependencies,monorepo/no-internal-import */ +/* eslint-disable monorepo/no-relative-import */ /* This is a list of all packages used by the template. If dependencies are added or removed, this list should be updated as well. -The list, and the accompanying devDependencies entries, are here to ensure correct versioning +The list, and the accompanying peerDependencies entries, are here to ensure correct versioning and bumping of this package. Without this list the version would not be bumped unless we manually trigger a release. This does not create an actual dependency on these packages and does not bring in any code. +Relative imports are used rather than package imports to make sure the packages aren't externalized. Rollup will extract the value of the version field in each package at build time without leaving any imports in place. */ -import { version as backendCommon } from '@backstage/backend-common/package.json'; -import { version as catalogModel } from '@backstage/catalog-model/package.json'; -import { version as cli } from '@backstage/cli/package.json'; -import { version as config } from '@backstage/config/package.json'; -import { version as core } from '@backstage/core/package.json'; -import { version as pluginApiDocs } from '@backstage/plugin-api-docs/package.json'; -import { version as pluginAppBackend } from '@backstage/plugin-app-backend/package.json'; -import { version as pluginAuthBackend } from '@backstage/plugin-auth-backend/package.json'; -import { version as pluginCatalog } from '@backstage/plugin-catalog/package.json'; -import { version as pluginCatalogBackend } from '@backstage/plugin-catalog-backend/package.json'; -import { version as pluginCatalogImport } from '@backstage/plugin-catalog-import/package.json'; -import { version as pluginCircleci } from '@backstage/plugin-circleci/package.json'; -import { version as pluginExplore } from '@backstage/plugin-explore/package.json'; -import { version as pluginGithubActions } from '@backstage/plugin-github-actions/package.json'; -import { version as pluginLighthouse } from '@backstage/plugin-lighthouse/package.json'; -import { version as pluginProxyBackend } from '@backstage/plugin-proxy-backend/package.json'; -import { version as pluginRollbarBackend } from '@backstage/plugin-rollbar-backend/package.json'; -import { version as pluginScaffolder } from '@backstage/plugin-scaffolder/package.json'; -import { version as pluginScaffolderBackend } from '@backstage/plugin-scaffolder-backend/package.json'; -import { version as pluginSearch } from '@backstage/plugin-search/package.json'; -import { version as pluginTechRadar } from '@backstage/plugin-tech-radar/package.json'; -import { version as pluginTechdocs } from '@backstage/plugin-techdocs/package.json'; -import { version as pluginTechdocsBackend } from '@backstage/plugin-techdocs-backend/package.json'; -import { version as pluginUserSettings } from '@backstage/plugin-user-settings/package.json'; -import { version as testUtils } from '@backstage/test-utils/package.json'; -import { version as theme } from '@backstage/theme/package.json'; +import { version as backendCommon } from '../../../backend-common/package.json'; +import { version as catalogModel } from '../../../catalog-model/package.json'; +import { version as cli } from '../../../cli/package.json'; +import { version as config } from '../../../config/package.json'; +import { version as core } from '../../../core/package.json'; +import { version as testUtils } from '../../../test-utils/package.json'; +import { version as theme } from '../../../theme/package.json'; + +import { version as pluginApiDocs } from '../../../../plugins/api-docs/package.json'; +import { version as pluginAppBackend } from '../../../../plugins/app-backend/package.json'; +import { version as pluginAuthBackend } from '../../../../plugins/auth-backend/package.json'; +import { version as pluginCatalog } from '../../../../plugins/catalog/package.json'; +import { version as pluginCatalogReact } from '../../../../plugins/catalog-react/package.json'; +import { version as pluginCatalogBackend } from '../../../../plugins/catalog-backend/package.json'; +import { version as pluginCatalogImport } from '../../../../plugins/catalog-import/package.json'; +import { version as pluginCircleci } from '../../../../plugins/circleci/package.json'; +import { version as pluginExplore } from '../../../../plugins/explore/package.json'; +import { version as pluginGithubActions } from '../../../../plugins/github-actions/package.json'; +import { version as pluginLighthouse } from '../../../../plugins/lighthouse/package.json'; +import { version as pluginProxyBackend } from '../../../../plugins/proxy-backend/package.json'; +import { version as pluginRollbarBackend } from '../../../../plugins/rollbar-backend/package.json'; +import { version as pluginScaffolder } from '../../../../plugins/scaffolder/package.json'; +import { version as pluginScaffolderBackend } from '../../../../plugins/scaffolder-backend/package.json'; +import { version as pluginSearch } from '../../../../plugins/search/package.json'; +import { version as pluginTechRadar } from '../../../../plugins/tech-radar/package.json'; +import { version as pluginTechdocs } from '../../../../plugins/techdocs/package.json'; +import { version as pluginTechdocsBackend } from '../../../../plugins/techdocs-backend/package.json'; +import { version as pluginUserSettings } from '../../../../plugins/user-settings/package.json'; export const packageVersions = { '@backstage/backend-common': backendCommon, @@ -66,6 +69,7 @@ export const packageVersions = { '@backstage/plugin-app-backend': pluginAppBackend, '@backstage/plugin-auth-backend': pluginAuthBackend, '@backstage/plugin-catalog': pluginCatalog, + '@backstage/plugin-catalog-react': pluginCatalogReact, '@backstage/plugin-catalog-backend': pluginCatalogBackend, '@backstage/plugin-catalog-import': pluginCatalogImport, '@backstage/plugin-circleci': pluginCircleci, diff --git a/packages/create-app/templates/default-app/.gitignore.hbs b/packages/create-app/templates/default-app/.gitignore.hbs index 5f5cc739f4..4adebc5adc 100644 --- a/packages/create-app/templates/default-app/.gitignore.hbs +++ b/packages/create-app/templates/default-app/.gitignore.hbs @@ -30,4 +30,7 @@ dist-types site # Local configuration files -*.local.yaml \ No newline at end of file +*.local.yaml + +# Sensitive credentials +*-credentials.yaml 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 7b70a2192c..495b30dd6f 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -56,17 +56,13 @@ proxy: target: 'https://example.com' changeOrigin: true +# Reference documentation http://backstage.io/docs/features/techdocs/configuration techdocs: - requestUrl: http://localhost:7000/api/techdocs - storageUrl: http://localhost:7000/api/techdocs/static/docs - builder: 'local' + builder: 'local' # Alternatives - 'external' generators: - techdocs: 'docker' + techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' - -lighthouse: - baseUrl: http://localhost:3003 + type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. auth: # see https://backstage.io/docs/tutorials/quickstart-app-auth to know more about enabling auth providers @@ -91,23 +87,23 @@ catalog: target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml # Backstage example templates - - type: github + - type: url target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml rules: - allow: [Template] - - type: github + - type: url target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml rules: - allow: [Template] - - type: github + - type: url target: https://github.com/backstage/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/backstage/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 index 0f8e4f5aeb..d66da552b7 100644 --- a/packages/create-app/templates/default-app/catalog-info.yaml.hbs +++ b/packages/create-app/templates/default-app/catalog-info.yaml.hbs @@ -6,7 +6,7 @@ metadata: # Example for optional annotations # annotations: # github.com/project-slug: backstage/backstage - # backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + # backstage.io/techdocs-ref: url:https://github.com/backstage/backstage spec: type: website owner: john@example.com diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index 1353e33072..3fa350fa7c 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -45,7 +45,7 @@ }, "jest": { "transformModules": [ - "@kyma-project/asyncapi-react" + "@asyncapi/react-component" ] } } diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index 48ab600c97..c7561fe091 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -10,12 +10,12 @@ "@backstage/core": "^{{version '@backstage/core'}}", "@backstage/plugin-api-docs": "^{{version '@backstage/plugin-api-docs'}}", "@backstage/plugin-catalog": "^{{version '@backstage/plugin-catalog'}}", + "@backstage/plugin-catalog-react": "^{{version '@backstage/plugin-catalog-react'}}", "@backstage/plugin-catalog-import": "^{{version '@backstage/plugin-catalog-import'}}", "@backstage/plugin-scaffolder": "^{{version '@backstage/plugin-scaffolder'}}", "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", "@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}", "@backstage/plugin-circleci": "^{{version '@backstage/plugin-circleci'}}", - "@backstage/plugin-lighthouse": "^{{version '@backstage/plugin-lighthouse'}}", "@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}", "@backstage/plugin-github-actions": "^{{version '@backstage/plugin-github-actions'}}", "@backstage/plugin-user-settings": "^{{version '@backstage/plugin-user-settings'}}", diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 693e66e0c6..6deedcd748 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -5,16 +5,18 @@ import { OAuthRequestDialog, SidebarPage, createRouteRef, + FlatRoutes, } from '@backstage/core'; import { apis } from './apis'; import * as plugins from './plugins'; import { AppSidebar } from './sidebar'; -import { Route, Routes, Navigate } from 'react-router'; +import { Route, Navigate } from 'react-router'; import { Router as CatalogRouter } from '@backstage/plugin-catalog'; import { Router as DocsRouter } from '@backstage/plugin-techdocs'; import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import'; import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; import { SearchPage as SearchRouter } from '@backstage/plugin-search'; +import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; import { EntityPage } from './components/catalog/EntityPage'; @@ -40,13 +42,13 @@ const App = () => ( - + } /> - } /> + } /> } @@ -59,8 +61,9 @@ const App = () => ( path="/search" element={} /> + } /> {deprecatedAppRoutes} - + diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx index b5e384f7a9..a3a3715ab0 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -24,8 +24,10 @@ import { } from '@backstage/plugin-api-docs'; import { AboutCard, EntityPageLayout, - useEntity } from '@backstage/plugin-catalog'; +import { + useEntity +} from '@backstage/plugin-catalog-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter } from '@backstage/plugin-circleci'; diff --git a/packages/create-app/templates/default-app/packages/app/src/plugins.ts b/packages/create-app/templates/default-app/packages/app/src/plugins.ts index 182ec3c51c..28b42d5be2 100644 --- a/packages/create-app/templates/default-app/packages/app/src/plugins.ts +++ b/packages/create-app/templates/default-app/packages/app/src/plugins.ts @@ -3,7 +3,8 @@ export { plugin as CatalogPlugin } from '@backstage/plugin-catalog'; export { plugin as CatalogImport } from '@backstage/plugin-catalog-import'; export { plugin as Circleci } from '@backstage/plugin-circleci'; export { plugin as GithubActions } from '@backstage/plugin-github-actions'; -export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; export { plugin as TechDocsPlugin } from '@backstage/plugin-techdocs'; export { plugin as TechRadar } from '@backstage/plugin-tech-radar'; +export { plugin as UserSettings } from '@backstage/plugin-user-settings'; + diff --git a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx index a5595e2c09..9f7973c387 100644 --- a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx @@ -3,8 +3,6 @@ import HomeIcon from '@material-ui/icons/Home'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import ExtensionIcon from '@material-ui/icons/Extension'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import BuildIcon from '@material-ui/icons/BuildRounded'; -import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; import { Link, makeStyles } from '@material-ui/core'; import { NavLink } from 'react-router-dom'; @@ -22,27 +20,6 @@ import { } from '@backstage/core'; import { SidebarSearch } from '@backstage/plugin-search'; -export const AppSidebar = () => ( - - - - - {/* Global nav, not org-specific */} - - - - - - - - - {/* End global nav */} - - - - - -); const useSidebarLogoStyles = makeStyles({ root: { @@ -76,3 +53,23 @@ const SidebarLogo = () => { ); }; + +export const AppSidebar = () => ( + + + + + {/* Global nav, not org-specific */} + + + + + + + {/* End global nav */} + + + + + +); 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 2c8f460a35..3fed72f07b 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 @@ -27,8 +27,8 @@ "@backstage/plugin-proxy-backend": "^{{version '@backstage/plugin-proxy-backend'}}", "@backstage/plugin-scaffolder-backend": "^{{version '@backstage/plugin-scaffolder-backend'}}", "@backstage/plugin-techdocs-backend": "^{{version '@backstage/plugin-techdocs-backend'}}", - "@octokit/rest": "^18.0.0", - "@gitbeaker/node": "^25.2.0", + "@gitbeaker/node": "^28.0.2", + "@octokit/rest": "^18.0.12", "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", @@ -37,7 +37,7 @@ "pg": "^8.3.0", {{/if}} {{#if dbTypeSqlite}} - "sqlite3": "^4.2.0", + "sqlite3": "^5.0.0", {{/if}} "winston": "^3.2.1" }, @@ -45,8 +45,7 @@ "@backstage/cli": "^{{version '@backstage/cli'}}", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", - "@types/express-serve-static-core": "^4.17.5", - "@types/helmet": "^0.0.47" + "@types/express-serve-static-core": "^4.17.5" }, "files": [ "dist" 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 2dc69feb45..d68f90ce08 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 @@ -1,102 +1,30 @@ import { CookieCutter, createRouter, - FilePreparer, - GithubPreparer, - GitlabPreparer, Preparers, Publishers, - GithubPublisher, - GitlabPublisher, CreateReactAppTemplater, Templaters, - RepoVisibilityOptions, CatalogEntityClient, } from '@backstage/plugin-scaffolder-backend'; import { SingleHostDiscovery } from '@backstage/backend-common'; -import { Octokit } from '@octokit/rest'; -import { Gitlab } from '@gitbeaker/node'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; export default async function createPlugin({ logger, config, + database, }: PluginEnvironment) { const cookiecutterTemplater = new CookieCutter(); const craTemplater = new CreateReactAppTemplater(); const templaters = new Templaters(); + templaters.register('cookiecutter', cookiecutterTemplater); templaters.register('cra', craTemplater); - const filePreparer = new FilePreparer(); - const githubPreparer = new GithubPreparer(); - const gitlabPreparer = new GitlabPreparer(config); - const preparers = new Preparers(); - - preparers.register('file', filePreparer); - preparers.register('github', githubPreparer); - preparers.register('gitlab', gitlabPreparer); - preparers.register('gitlab/api', gitlabPreparer); - - const publishers = new Publishers(); - - const githubConfig = config.getOptionalConfig('scaffolder.github'); - - if (githubConfig) { - try { - const repoVisibility = githubConfig.getString( - 'visibility', - ) as RepoVisibilityOptions; - - const githubToken = githubConfig.getString('token'); - const githubHost = githubConfig.getOptionalString('host'); - const githubClient = new Octokit({ auth: githubToken, baseUrl: githubHost }); - const githubPublisher = new GithubPublisher({ - client: githubClient, - token: githubToken, - repoVisibility, - }); - publishers.register('file', githubPublisher); - publishers.register('github', githubPublisher); - } catch (e) { - const providerName = 'github'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } - - const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab.api'); - if (gitLabConfig) { - try { - const gitLabToken = gitLabConfig.getString('token'); - const gitLabClient = new Gitlab({ - host: gitLabConfig.getOptionalString('baseUrl'), - token: gitLabToken, - }); - const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); - publishers.register('gitlab', gitLabPublisher); - publishers.register('gitlab/api', gitLabPublisher); - } catch (e) { - const providerName = 'gitlab'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } + const preparers = await Preparers.fromConfig(config, { logger }); + const publishers = await Publishers.fromConfig(config, { logger }); const dockerClient = new Docker(); @@ -111,5 +39,6 @@ export default async function createPlugin({ config, dockerClient, entityClient, + database, }); } diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index 1938754169..251a4db7ee 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,44 @@ # @backstage/dev-utils +## 0.1.9 + +### Patch Changes + +- 720149854: Added `path` option to `addPage` that can be used to set a specific path for the page rather than a generated one. Also omit sidebar item altogether if `title` option is not set. +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.1.8 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + ## 0.1.7 ### Patch Changes diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 785607c6d3..3a01306b46 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.7", + "version": "0.1.9", "private": false, "publishConfig": { "access": "public", @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/catalog-model": "^0.6.0", - "@backstage/plugin-catalog": "^0.2.9", + "@backstage/core": "^0.6.0", + "@backstage/catalog-model": "^0.7.1", + "@backstage/plugin-catalog-react": "^0.0.2", "@backstage/test-utils": "^0.1.5", - "@backstage/theme": "^0.2.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", @@ -47,7 +47,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@backstage/cli": "^0.6.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx index 077a8f85e8..825a207064 100644 --- a/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx +++ b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ -import React from 'react'; -import { Grid, GridProps, makeStyles } from '@material-ui/core'; import { Entity } from '@backstage/catalog-model'; -import { EntityProvider } from '@backstage/plugin-catalog'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { BackstageTheme } from '@backstage/theme'; +import { Grid, GridProps, makeStyles } from '@material-ui/core'; +import React from 'react'; const useStyles = makeStyles(theme => ({ root: ({ entity }) => ({ diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index f1a6d30b4c..e1bdf4d0e9 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -34,17 +34,19 @@ import { attachComponentData, } from '@backstage/core'; import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied'; -import { Outlet } from 'react-router'; const GatheringRoute: (props: { path: string; - children: JSX.Element; -}) => JSX.Element = () => ; + element: JSX.Element; + children?: ReactNode; +}) => JSX.Element = ({ element }) => element; attachComponentData(GatheringRoute, 'core.gatherMountPoints', true); type RegisterPageOptions = { + path?: string; element: JSX.Element; + children?: JSX.Element; title?: string; icon?: IconComponent; }; @@ -93,21 +95,35 @@ class DevAppBuilder { return this; } - addPage({ element, title, icon }: RegisterPageOptions): DevAppBuilder { - const path = `/page-${this.routes.length + 1}`; - this.sidebarItems.push( - , - ); + /** + * Adds a page component along with accompanying sidebar item. + * + * If no path is provided one will be generated. + * If no title is provided, no sidebar item will be created. + */ + addPage(opts: RegisterPageOptions): DevAppBuilder { + const path = opts.path ?? `/page-${this.routes.length + 1}`; + if (opts.title) { + this.sidebarItems.push( + , + ); + } this.routes.push( - , + , ); return this; } + /** * Build a DevApp component using the resources registered so far */ diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json index ea1dcc8ea2..4327d7161c 100644 --- a/packages/e2e-test/package.json +++ b/packages/e2e-test/package.json @@ -15,7 +15,7 @@ "license": "Apache-2.0", "main": "src/index.ts", "scripts": { - "start": "node .", + "start": "nodemon --", "lint": "backstage-cli lint", "test": "backstage-cli test", "test:e2e": "yarn start" @@ -36,5 +36,10 @@ "tree-kill": "^1.2.2", "ts-node": "^8.6.2", "zombie": "^6.1.4" + }, + "nodemonConfig": { + "watch": "./src", + "exec": "bin/e2e-test", + "ext": "ts" } } diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index be00b99e54..5f4894f9e9 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -80,10 +80,22 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) { // We grab the needed dependencies from the create app template const createAppDeps = new Set(); + + function appendDeps(pkg: any) { + Array() + .concat( + Object.keys(pkg.dependencies ?? {}), + Object.keys(pkg.devDependencies ?? {}), + Object.keys(pkg.peerDependencies ?? {}), + ) + .filter(name => name.startsWith('@backstage/')) + .forEach(dep => createAppDeps.add(dep)); + } + for (const pkgJsonPath of templatePackagePaths) { const path = paths.resolveOwnRoot(pkgJsonPath); const pkgTemplate = await fs.readFile(path, 'utf8'); - const { dependencies = {}, devDependencies = {} } = JSON.parse( + const pkg = JSON.parse( handlebars.compile(pkgTemplate)( { privatePackage: true, @@ -92,23 +104,22 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) { { helpers: { version(name: string) { - const pkg = require(`${name}/package.json`); - if (!pkg) { + const pkge = require(`${name}/package.json`); + if (!pkge) { throw new Error(`No version available for package ${name}`); } - return pkg.version; + return pkge.version; }, }, }, ), ); - - Array() - .concat(Object.keys(dependencies), Object.keys(devDependencies)) - .filter(name => name.startsWith('@backstage/')) - .forEach(dep => createAppDeps.add(dep)); + appendDeps(pkg); } + // eslint-disable-next-line import/no-extraneous-dependencies + appendDeps(require('@backstage/create-app/package.json')); + print(`Preparing workspace`); await runPlain([ 'yarn', diff --git a/packages/e2e-test/src/lib/helpers.ts b/packages/e2e-test/src/lib/helpers.ts index 0b05eaf4b4..190ff403d9 100644 --- a/packages/e2e-test/src/lib/helpers.ts +++ b/packages/e2e-test/src/lib/helpers.ts @@ -152,7 +152,7 @@ export async function waitForPageWithText( // The page may not be fully loaded and hence we need to retry. let findTextAttempts = 0; - const escapedText = text.replace(/"/g, '\\"'); + const escapedText = text.replace(/"|\\/g, '\\$&'); for (;;) { try { browser.assert.evaluate( diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 5559c5230d..11839d733b 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,114 @@ # @backstage/integration +## 0.3.2 + +### Patch Changes + +- c4abcdb60: Fix GitLab handling of paths with spaces +- 064c513e1: Properly forward errors that occur when looking up GitLab project IDs. +- 3149bfe63: Add a `resolveUrl` method to integrations, that works like the two-argument URL + constructor. The reason for using this is that Azure have their paths in a + query parameter, rather than the pathname of the URL. + + The implementation is optional (when not present, the URL constructor is used), + so this does not imply a breaking change. + +- 2e62aea6f: #4322 Bitbucket own hosted v5.11.1 branchUrl fix and enabled error tracing… #4347 + +## 0.3.1 + +### Patch Changes + +- 6800da78d: Fix default branch API url for custom hosted Bitbucket server +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. + +## 0.3.0 + +### Minor Changes + +- ed6baab66: - Deprecating the `scaffolder.${provider}.token` auth duplication and favoring `integrations.${provider}` instead. If you receive deprecation warnings your config should change like the following: + + ```yaml + scaffolder: + github: + token: + $env: GITHUB_TOKEN + visibility: public + ``` + + To something that looks like this: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + scaffolder: + github: + visibility: public + ``` + + You can also configure multiple different hosts under the `integration` config like the following: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + - host: ghe.mycompany.com + token: + $env: GITHUB_ENTERPRISE_TOKEN + ``` + + This of course is the case for all the providers respectively. + + - Adding support for cross provider scaffolding, you can now create repositories in for example Bitbucket using a template residing in GitHub. + + - Fix GitLab scaffolding so that it returns a `catalogInfoUrl` which automatically imports the project into the catalog. + + - The `Store Path` field on the `scaffolder` frontend has now changed so that you require the full URL to the desired destination repository. + + `backstage/new-repository` would become `https://github.com/backstage/new-repository` if provider was GitHub for example. + +### Patch Changes + +- 0b135e7e0: Add support for GitHub Apps authentication for backend plugins. + + `GithubCredentialsProvider` requests and caches GitHub credentials based on a repository or organization url. + + The `GithubCredentialsProvider` class should be considered stateful since tokens will be cached internally. + Consecutive calls to get credentials will return the same token, tokens older than 50 minutes will be considered expired and reissued. + `GithubCredentialsProvider` will default to the configured access token if no GitHub Apps are configured. + + More information on how to create and configure a GitHub App to use with backstage can be found in the documentation. + + Usage: + + ```javascript + const credentialsProvider = new GithubCredentialsProvider(config); + const { token, headers } = await credentialsProvider.getCredentials({ + url: 'https://github.com/', + }); + ``` + + Updates `GithubUrlReader` to use the `GithubCredentialsProvider`. + +- fa8ba330a: Fix GitLab API base URL and add it by default to the gitlab.com host + +## 0.2.0 + +### Minor Changes + +- 466354aaa: Build out the `ScmIntegrations` class, as well as the individual `*Integration` classes + +## 0.1.5 + +### Patch Changes + +- 036a84373: Provide support for on-prem azure devops + ## 0.1.4 ### Patch Changes diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index a03a07409b..7d670ea505 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -15,31 +15,117 @@ */ export interface Config { + /** Configuration for integrations towards various external repository provider systems */ integrations?: { + /** Integration configuration for Azure */ azure?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given Azure instance + * @visibility frontend + */ host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; }>; + /** Integration configuration for Bitbucket */ bitbucket?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given Bitbucket instance + * @visibility frontend + */ host: string; - /** @visibility frontend */ + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** + * The base url for the Bitbucket API, for example https://api.bitbucket.org/2.0 + * @visibility frontend + */ apiBaseUrl?: string; + /** + * The username to use for authenticated requests. + * @visibility secret + */ + username?: string; + /** + * Bitbucket app password used to authenticate requests. + * @visibility secret + */ + appPassword?: string; }>; + /** Integration configuration for GitHub */ github?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given GitHub instance + * @visibility frontend + */ host: string; - /** @visibility frontend */ + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** + * The base url for the GitHub API, for example https://api.github.com + * @visibility frontend + */ apiBaseUrl?: string; - /** @visibility frontend */ + /** + * The base url for GitHub raw resources, for example https://raw.githubusercontent.com + * @visibility frontend + */ rawBaseUrl?: string; + + /** + * GitHub Apps configuration + * @visibility backend + */ + apps?: Array<{ + /** + * The numeric GitHub App ID + */ + appId: number; + /** + * The private key to use for auth against the app + * @visibility secret + */ + privateKey: string; + /** + * The secret used for webhooks + * @visibility secret + */ + webhookSecret: string; + /** + * The client ID to use + */ + clientId: string; + /** + * The client secret to use + * @visibility secret + */ + clientSecret: string; + }>; }>; + /** Integration configuration for GitLab */ gitlab?: Array<{ - /** @visibility frontend */ + /** + * The hostname of the given GitLab instance + * @visibility frontend + */ host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; }>; }; } diff --git a/packages/integration/package.json b/packages/integration/package.json index fe5bd60905..f82472ca97 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.1.4", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,12 +31,16 @@ "dependencies": { "@backstage/config": "^0.1.2", "cross-fetch": "^3.0.6", - "git-url-parse": "^11.4.3" + "git-url-parse": "^11.4.4", + "@octokit/rest": "^18.0.12", + "@octokit/auth-app": "^2.10.5", + "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.6.0", "@backstage/test-utils": "^0.1.5", "@types/jest": "^26.0.7", + "@types/luxon": "^1.25.0", "msw": "^0.21.2" }, "files": [ diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts new file mode 100644 index 0000000000..44e427a5d0 --- /dev/null +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AzureIntegrationConfig } from './azure'; +import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketIntegrationConfig } from './bitbucket'; +import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GitHubIntegrationConfig } from './github'; +import { GitHubIntegration } from './github/GitHubIntegration'; +import { GitLabIntegrationConfig } from './gitlab'; +import { GitLabIntegration } from './gitlab/GitLabIntegration'; +import { basicIntegrations } from './helpers'; +import { ScmIntegrations } from './ScmIntegrations'; + +describe('ScmIntegrations', () => { + const azure = new AzureIntegration({ + host: 'azure.local', + } as AzureIntegrationConfig); + + const bitbucket = new BitbucketIntegration({ + host: 'bitbucket.local', + } as BitbucketIntegrationConfig); + + const github = new GitHubIntegration({ + host: 'github.local', + } as GitHubIntegrationConfig); + + const gitlab = new GitLabIntegration({ + host: 'gitlab.local', + } as GitLabIntegrationConfig); + + const i = new ScmIntegrations({ + azure: basicIntegrations([azure], item => item.config.host), + bitbucket: basicIntegrations([bitbucket], item => item.config.host), + github: basicIntegrations([github], item => item.config.host), + gitlab: basicIntegrations([gitlab], item => item.config.host), + }); + + it('can get the specifics', () => { + expect(i.azure.byUrl('https://azure.local')).toBe(azure); + expect(i.bitbucket.byUrl('https://bitbucket.local')).toBe(bitbucket); + expect(i.github.byUrl('https://github.local')).toBe(github); + expect(i.gitlab.byUrl('https://gitlab.local')).toBe(gitlab); + }); + + it('can list', () => { + expect(i.list()).toEqual( + expect.arrayContaining([azure, bitbucket, github, gitlab]), + ); + }); + + it('can select by url and host', () => { + expect(i.byUrl('https://azure.local')).toBe(azure); + expect(i.byUrl('https://bitbucket.local')).toBe(bitbucket); + expect(i.byUrl('https://github.local')).toBe(github); + expect(i.byUrl('https://gitlab.local')).toBe(gitlab); + + expect(i.byHost('azure.local')).toBe(azure); + expect(i.byHost('bitbucket.local')).toBe(bitbucket); + expect(i.byHost('github.local')).toBe(github); + expect(i.byHost('gitlab.local')).toBe(gitlab); + }); + + it('can resolveUrl using fallback', () => { + expect( + i.resolveUrl({ + url: '../b.yaml', + base: 'https://no-matching-integration.com/x/a.yaml', + }), + ).toBe('https://no-matching-integration.com/b.yaml'); + expect( + i.resolveUrl({ + url: 'https://absolute.com/path', + base: 'https://no-matching-integration.com/x/a.yaml', + }), + ).toBe('https://absolute.com/path'); + }); +}); diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index 515a32502d..c20fdcef9c 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -21,27 +21,73 @@ import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; import { ScmIntegration, - ScmIntegrationPredicateTuple, ScmIntegrationRegistry, + ScmIntegrationsGroup, } from './types'; +type IntegrationsByType = { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; +}; + export class ScmIntegrations implements ScmIntegrationRegistry { + private readonly byType: IntegrationsByType; + static fromConfig(config: Config): ScmIntegrations { - return new ScmIntegrations([ - ...AzureIntegration.factory({ config }), - ...BitbucketIntegration.factory({ config }), - ...GitHubIntegration.factory({ config }), - ...GitLabIntegration.factory({ config }), - ]); + return new ScmIntegrations({ + azure: AzureIntegration.factory({ config }), + bitbucket: BitbucketIntegration.factory({ config }), + github: GitHubIntegration.factory({ config }), + gitlab: GitLabIntegration.factory({ config }), + }); } - constructor(private readonly integrations: ScmIntegrationPredicateTuple[]) {} + constructor(integrationsByType: IntegrationsByType) { + this.byType = integrationsByType; + } + + get azure(): ScmIntegrationsGroup { + return this.byType.azure; + } + + get bitbucket(): ScmIntegrationsGroup { + return this.byType.bitbucket; + } + + get github(): ScmIntegrationsGroup { + return this.byType.github; + } + + get gitlab(): ScmIntegrationsGroup { + return this.byType.gitlab; + } list(): ScmIntegration[] { - return this.integrations.map(i => i.integration); + return Object.values(this.byType).flatMap( + i => i.list() as ScmIntegration[], + ); } - byUrl(url: string): ScmIntegration | undefined { - return this.integrations.find(i => i.predicate(new URL(url)))?.integration; + byUrl(url: string | URL): ScmIntegration | undefined { + return Object.values(this.byType) + .map(i => i.byUrl(url)) + .find(Boolean); + } + + byHost(host: string): ScmIntegration | undefined { + return Object.values(this.byType) + .map(i => i.byHost(host)) + .find(Boolean); + } + + resolveUrl(options: { url: string; base: string }): string { + const resolve = this.byUrl(options.base)?.resolveUrl; + if (!resolve) { + return new URL(options.url, options.base).toString(); + } + + return resolve(options); } } diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts index 4a0c32badb..8a10e40bcf 100644 --- a/packages/integration/src/azure/AzureIntegration.test.ts +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -31,8 +31,9 @@ describe('AzureIntegration', () => { }, }), }); - expect(integrations.length).toBe(2); // including default - expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('dev.azure.com'); }); it('returns the basics', () => { @@ -40,4 +41,52 @@ describe('AzureIntegration', () => { expect(integration.type).toBe('azure'); expect(integration.title).toBe('h.com'); }); + + describe('resolveUrl', () => { + it('works for valid urls', () => { + const integration = new AzureIntegration({ + host: 'dev.azure.com', + } as any); + + expect( + integration.resolveUrl({ + url: '../a.yaml', + base: + 'https://dev.azure.com/organization/project/_git/repository?path=%2Ffolder%2Fcatalog-info.yaml', + }), + ).toBe( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', + ); + + expect( + integration.resolveUrl({ + url: './a.yaml', + base: 'https://dev.azure.com/organization/project/_git/repository', + }), + ).toBe( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', + ); + + expect( + integration.resolveUrl({ + url: 'https://absolute.com/path', + base: + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml', + }), + ).toBe('https://absolute.com/path'); + }); + + it('falls back to regular URL resolution if not in a repo', () => { + const integration = new AzureIntegration({ + host: 'dev.azure.com', + } as any); + + expect( + integration.resolveUrl({ + url: './test', + base: 'https://dev.azure.com/organization/project/_git', + }), + ).toBe('https://dev.azure.com/organization/project/test'); + }); + }); }); diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts index 84dc3800d2..1413b7b343 100644 --- a/packages/integration/src/azure/AzureIntegration.ts +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -14,27 +14,68 @@ * limitations under the License. */ -import { ScmIntegration, ScmIntegrationFactory } from '../types'; +import parseGitUrl from 'git-url-parse'; +import { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config'; export class AzureIntegration implements ScmIntegration { - static factory: ScmIntegrationFactory = ({ config }) => { + static factory: ScmIntegrationsFactory = ({ config }) => { const configs = readAzureIntegrationConfigs( config.getOptionalConfigArray('integrations.azure') ?? [], ); - return configs.map(integration => ({ - predicate: (url: URL) => url.host === integration.host, - integration: new AzureIntegration(integration), - })); + return basicIntegrations( + configs.map(c => new AzureIntegration(c)), + i => i.config.host, + ); }; - constructor(private readonly config: AzureIntegrationConfig) {} + constructor(private readonly integrationConfig: AzureIntegrationConfig) {} get type(): string { return 'azure'; } get title(): string { - return this.config.host; + return this.integrationConfig.host; + } + + get config(): AzureIntegrationConfig { + return this.integrationConfig; + } + + /* + * Azure repo URLs on the form with a `path` query param are treated specially. + * + * Example base URL: https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml + */ + resolveUrl(options: { url: string; base: string }): string { + const { url, base } = options; + + // If we can parse the url, it is absolute - then return it verbatim + try { + // eslint-disable-next-line no-new + new URL(url); + return url; + } catch { + // Ignore intentionally - looks like a relative path + } + + const parsed = parseGitUrl(base); + const { organization, owner, name, filepath } = parsed; + + // If not an actual file path within a repo, treat the URL as raw + if (!organization || !owner || !name) { + return new URL(url, base).toString(); + } + + const path = filepath?.replace(/^\//, '') || ''; + const mockBaseUrl = new URL(`https://a.com/${path}`); + const updatedPath = new URL(url, mockBaseUrl).pathname; + + const newUrl = new URL(base); + newUrl.searchParams.set('path', updatedPath); + + return newUrl.toString(); } } diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts index 7900774c79..b2878af89e 100644 --- a/packages/integration/src/azure/core.ts +++ b/packages/integration/src/azure/core.ts @@ -108,6 +108,62 @@ export function getAzureDownloadUrl(url: string): string { return `${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`; } +/** + * Given a URL, return the API URL to fetch commits on the branch. + * + * @param url A URL pointing to a repository or a sub-path + */ +export function getAzureCommitsUrl(url: string): string { + try { + const parsedUrl = new URL(url); + + const [ + empty, + userOrOrg, + project, + srcKeyword, + repoName, + ] = parsedUrl.pathname.split('/'); + + // Remove the "GB" from "GBmain" for example. + const ref = parsedUrl.searchParams.get('version')?.substr(2); + + if ( + !!empty || + !userOrOrg || + !project || + srcKeyword !== '_git' || + !repoName + ) { + throw new Error('Wrong Azure Devops URL'); + } + + // transform to commits api + parsedUrl.pathname = [ + empty, + userOrOrg, + project, + '_apis', + 'git', + 'repositories', + repoName, + 'commits', + ].join('/'); + + const queryParams = []; + if (ref) { + queryParams.push(`searchCriteria.itemVersion.version=${ref}`); + } + parsedUrl.search = queryParams.join('&'); + + parsedUrl.protocol = 'https'; + + return parsedUrl.toString(); + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + /** * Gets the request options necessary to make requests to a given provider. * diff --git a/packages/integration/src/azure/index.ts b/packages/integration/src/azure/index.ts index 365e4cdcdc..6d57437779 100644 --- a/packages/integration/src/azure/index.ts +++ b/packages/integration/src/azure/index.ts @@ -23,4 +23,5 @@ export { getAzureDownloadUrl, getAzureFileFetchUrl, getAzureRequestOptions, + getAzureCommitsUrl, } from './core'; diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts index 87dbac2263..3f130a393c 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -34,8 +34,9 @@ describe('BitbucketIntegration', () => { }, }), }); - expect(integrations.length).toBe(2); // including default - expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('bitbucket.org'); }); it('returns the basics', () => { diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts index b271e2f408..f3e69b946a 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -14,30 +14,37 @@ * limitations under the License. */ -import { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { BitbucketIntegrationConfig, readBitbucketIntegrationConfigs, } from './config'; export class BitbucketIntegration implements ScmIntegration { - static factory: ScmIntegrationFactory = ({ config }) => { + static factory: ScmIntegrationsFactory = ({ + config, + }) => { const configs = readBitbucketIntegrationConfigs( config.getOptionalConfigArray('integrations.bitbucket') ?? [], ); - return configs.map(integration => ({ - predicate: (url: URL) => url.host === integration.host, - integration: new BitbucketIntegration(integration), - })); + return basicIntegrations( + configs.map(c => new BitbucketIntegration(c)), + i => i.config.host, + ); }; - constructor(private readonly config: BitbucketIntegrationConfig) {} + constructor(private readonly integrationConfig: BitbucketIntegrationConfig) {} get type(): string { return 'bitbucket'; } get title(): string { - return this.config.host; + return this.integrationConfig.host; + } + + get config(): BitbucketIntegrationConfig { + return this.integrationConfig; } } diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts index 39707976fe..22b17d51fa 100644 --- a/packages/integration/src/bitbucket/core.test.ts +++ b/packages/integration/src/bitbucket/core.test.ts @@ -116,7 +116,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), @@ -125,6 +125,7 @@ describe('bitbucket core', () => { ), ), ); + const config: BitbucketIntegrationConfig = { host: 'bitbucket.mycompany.net', apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', @@ -144,7 +145,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), @@ -230,6 +231,41 @@ describe('bitbucket core', () => { displayId: 'main', }; worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const defaultBranch = await getBitbucketDefaultBranch( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + + it('return default branch for Bitbucket Server for bitbucket version 5.11', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(404), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), rest.get( 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', (_, res, ctx) => diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index ae61df497d..8b1d7d6b18 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -31,11 +31,20 @@ export async function getBitbucketDefaultBranch( const { name: repoName, owner: project, resource } = parseGitUrl(url); const isHosted = resource === 'bitbucket.org'; - const branchUrl = isHosted + // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184 + let branchUrl = isHosted ? `${config.apiBaseUrl}/repositories/${project}/${repoName}` - : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`; + + let response = await fetch(branchUrl, getBitbucketRequestOptions(config)); + + if (response.status === 404 && !isHosted) { + // First try the new format, and then if it gets specifically a 404 it should try the old format + // (to support old Atlassian Bitbucket v5.11.1 format ) + branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + response = await fetch(branchUrl, getBitbucketRequestOptions(config)); + } - const response = await fetch(branchUrl, getBitbucketRequestOptions(config)); if (!response.ok) { const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; throw new Error(message); @@ -50,7 +59,10 @@ export async function getBitbucketDefaultBranch( defaultBranch = displayId; } if (!defaultBranch) { - throw new Error(`Failed to read default branch from ${branchUrl}`); + throw new Error( + `Failed to read default branch from ${branchUrl}. ` + + `Response ${response.status} ${response.json()}`, + ); } return defaultBranch; } diff --git a/packages/integration/src/github/GitHubIntegration.test.ts b/packages/integration/src/github/GitHubIntegration.test.ts index 0c326d81cd..9056517f32 100644 --- a/packages/integration/src/github/GitHubIntegration.test.ts +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -33,13 +33,20 @@ describe('GitHubIntegration', () => { }, }), }); - expect(integrations.length).toBe(2); // including default - expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('github.com'); }); it('returns the basics', () => { - const integration = new GitHubIntegration({ host: 'h.com' } as any); + const integration = new GitHubIntegration({ + host: 'h.com', + apiBaseUrl: 'a', + rawBaseUrl: 'r', + token: 't', + }); expect(integration.type).toBe('github'); expect(integration.title).toBe('h.com'); + expect(integration.config.host).toBe('h.com'); }); }); diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts index 92c5951873..c103597d74 100644 --- a/packages/integration/src/github/GitHubIntegration.ts +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -14,30 +14,35 @@ * limitations under the License. */ -import { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { GitHubIntegrationConfig, readGitHubIntegrationConfigs, } from './config'; export class GitHubIntegration implements ScmIntegration { - static factory: ScmIntegrationFactory = ({ config }) => { + static factory: ScmIntegrationsFactory = ({ config }) => { const configs = readGitHubIntegrationConfigs( config.getOptionalConfigArray('integrations.github') ?? [], ); - return configs.map(integration => ({ - predicate: (url: URL) => url.host === integration.host, - integration: new GitHubIntegration(integration), - })); + return basicIntegrations( + configs.map(c => new GitHubIntegration(c)), + i => i.config.host, + ); }; - constructor(private readonly config: GitHubIntegrationConfig) {} + constructor(private readonly integrationConfig: GitHubIntegrationConfig) {} get type(): string { return 'github'; } get title(): string { - return this.config.host; + return this.integrationConfig.host; + } + + get config(): GitHubIntegrationConfig { + return this.integrationConfig; } } diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts new file mode 100644 index 0000000000..d295677c55 --- /dev/null +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -0,0 +1,265 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 octokit = { + apps: { + listInstallations: jest.fn(), + createInstallationAccessToken: jest.fn(), + }, +}; + +jest.doMock('@octokit/rest', () => { + class Octokit { + constructor() { + return octokit; + } + } + return { Octokit }; +}); + +import { GithubCredentialsProvider } from './GithubCredentialsProvider'; +import { RestEndpointMethodTypes } from '@octokit/rest'; +import { DateTime } from 'luxon'; + +const github = GithubCredentialsProvider.create({ + host: 'github.com', + apps: [ + { + appId: 1, + privateKey: 'privateKey', + webhookSecret: '123', + clientId: 'CLIENT_ID', + clientSecret: 'CLIENT_SECRET', + }, + ], + token: 'hardcoded_token', +}); + +describe('GithubCredentialsProvider tests', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('create repository specific tokens', async () => { + octokit.apps.listInstallations.mockResolvedValueOnce({ + headers: { + etag: '123', + }, + data: [ + { + id: 1, + repository_selection: 'selected', + account: null, + }, + { + id: 2, + repository_selection: 'selected', + account: { + login: 'backstage', + }, + }, + ], + } as RestEndpointMethodTypes['apps']['listInstallations']['response']); + octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + + octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ + data: { + expires_at: DateTime.local().plus({ hour: 1 }).toString(), + token: 'secret_token', + }, + } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); + + const { token, headers } = await github.getCredentials({ + url: 'https://github.com/backstage/foobar', + }); + const { token: accessToken2 } = await github.getCredentials({ + url: 'https://github.com/backstage/foobar', + }); + + expect(token).toEqual('secret_token'); + expect(token).toEqual(accessToken2); + expect(headers).toEqual({ Authorization: 'Bearer secret_token' }); + + // fallback to the configured token if no application is matching + await expect( + github.getCredentials({ + url: 'https://github.com/404/foobar', + }), + ).resolves.toEqual({ + headers: { + Authorization: 'Bearer hardcoded_token', + }, + token: 'hardcoded_token', + }); + }); + + it('creates tokens for an organization', async () => { + octokit.apps.listInstallations.mockResolvedValueOnce({ + headers: { + etag: '123', + }, + data: [ + { + id: 1, + repository_selection: 'all', + account: { + login: 'backstage', + }, + }, + ], + } as RestEndpointMethodTypes['apps']['listInstallations']['response']); + octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + + octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ + data: { + expires_at: DateTime.local().plus({ hour: 1 }).toString(), + token: 'secret_token', + }, + } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); + + const { token, headers } = await github.getCredentials({ + url: 'https://github.com/backstage', + }); + const { token: accessToken2 } = await github.getCredentials({ + url: 'https://github.com/backstage', + }); + + expect(headers).toEqual({ Authorization: 'Bearer secret_token' }); + expect(token).toEqual('secret_token'); + expect(token).toEqual(accessToken2); + }); + + it('should fail to issue tokens for an organization when the app is installed for a single repo', async () => { + octokit.apps.listInstallations.mockResolvedValueOnce({ + headers: { + etag: '123', + }, + data: [ + { + id: 1, + repository_selection: 'selected', + account: { + login: 'backstage', + }, + }, + ], + } as RestEndpointMethodTypes['apps']['listInstallations']['response']); + octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + + octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ + data: { + expires_at: DateTime.local().plus({ hour: 1 }).toString(), + token: 'secret_token', + }, + } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); + + await expect( + github.getCredentials({ + url: 'https://github.com/backstage', + }), + ).rejects.toThrow( + 'The Backstage GitHub application used in the backstage organization must be installed for the entire organization to be able to issue credentials without a specified repository.', + ); + }); + + it('should throw if the app is suspended', async () => { + octokit.apps.listInstallations.mockResolvedValueOnce({ + headers: { + etag: '123', + }, + data: [ + { + id: 1, + suspended_by: { + login: 'admin', + }, + repository_selection: 'all', + account: { + login: 'backstage', + }, + }, + ], + } as RestEndpointMethodTypes['apps']['listInstallations']['response']); + octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + + await expect( + github.getCredentials({ + url: 'https://github.com/backstage', + }), + ).rejects.toThrow('The GitHub application for backstage is suspended'); + }); + + it('should return the default token when the call to github return a status that is not recognized', async () => { + octokit.apps.listInstallations.mockRejectedValue({ + status: 404, + message: 'NotFound', + }); + + await expect( + github.getCredentials({ + url: 'https://github.com/backstage', + }), + ).rejects.toEqual({ status: 404, message: 'NotFound' }); + }); + + it('should return the default token if no app is configured', async () => { + const githubProvider = GithubCredentialsProvider.create({ + host: 'github.com', + apps: [], + token: 'fallback_token', + }); + + await expect( + githubProvider.getCredentials({ + url: 'https://github.com/404/foobar', + }), + ).resolves.toEqual(expect.objectContaining({ token: 'fallback_token' })); + }); + + it('should return the configured token if listing installations throws', async () => { + const githubProvider = GithubCredentialsProvider.create({ + host: 'github.com', + apps: [ + { + appId: 1, + privateKey: 'privateKey', + webhookSecret: '123', + clientId: 'CLIENT_ID', + clientSecret: 'CLIENT_SECRET', + }, + ], + token: 'hardcoded_token', + }); + octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + + await expect( + githubProvider.getCredentials({ + url: 'https://github.com/backstage', + }), + ).resolves.toEqual(expect.objectContaining({ token: 'hardcoded_token' })); + }); + + it('should return undefined if no token or apps are configured', async () => { + const githubProvider = GithubCredentialsProvider.create({ + host: 'github.com', + }); + + await expect( + githubProvider.getCredentials({ + url: 'https://github.com/backstage', + }), + ).resolves.toEqual({ headers: undefined, token: undefined }); + }); +}); diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts new file mode 100644 index 0000000000..0bf217c824 --- /dev/null +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -0,0 +1,243 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import parseGitUrl from 'git-url-parse'; +import { GithubAppConfig, GitHubIntegrationConfig } from './config'; +import { createAppAuth } from '@octokit/auth-app'; +import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; +import { DateTime } from 'luxon'; + +type InstallationData = { + installationId: number; + suspended: boolean; + repositorySelection: 'selected' | 'all'; +}; + +class Cache { + private readonly tokenCache = new Map< + string, + { token: string; expiresAt: DateTime } + >(); + + async getOrCreateToken( + key: string, + supplier: () => Promise<{ token: string; expiresAt: DateTime }>, + ): Promise<{ accessToken: string }> { + const item = this.tokenCache.get(key); + if (item && this.isNotExpired(item.expiresAt)) { + return { accessToken: item.token }; + } + + const result = await supplier(); + this.tokenCache.set(key, result); + return { accessToken: result.token }; + } + + // consider timestamps older than 50 minutes to be expired. + private isNotExpired = (date: DateTime) => + date.diff(DateTime.local(), 'minutes').minutes > 50; +} + +/** + * This accept header is required when calling App APIs in GitHub Enterprise. + * It has no effect on calls to github.com and can probably be removed entirely + * once GitHub Apps is out of preview. + */ +const HEADERS = { + Accept: 'application/vnd.github.machine-man-preview+json', +}; + +/** + * GithubAppManager issues and caches tokens for a specific GitHub App. + */ +class GithubAppManager { + private readonly appClient: Octokit; + private readonly baseAuthConfig: { appId: number; privateKey: string }; + private installations?: RestEndpointMethodTypes['apps']['listInstallations']['response']; + private readonly cache = new Cache(); + + constructor(config: GithubAppConfig, baseUrl?: string) { + this.baseAuthConfig = { + appId: config.appId, + privateKey: config.privateKey, + }; + this.appClient = new Octokit({ + baseUrl, + headers: HEADERS, + authStrategy: createAppAuth, + auth: this.baseAuthConfig, + }); + } + + async getInstallationCredentials( + owner: string, + repo?: string, + ): Promise<{ accessToken: string }> { + const { + installationId, + suspended, + repositorySelection, + } = await this.getInstallationData(owner); + if (suspended) { + throw new Error( + `The GitHub application for ${[owner, repo] + .filter(Boolean) + .join('/')} is suspended`, + ); + } + if (repositorySelection !== 'all' && !repo) { + throw new Error( + `The Backstage GitHub application used in the ${owner} organization must be installed for the entire organization to be able to issue credentials without a specified repository.`, + ); + } + + const cacheKey = !repo ? owner : `${owner}/${repo}`; + const repositories = repositorySelection !== 'all' ? [repo!] : undefined; + + // Go and grab an access token for the app scoped to a repository if provided, if not use the organisation installation. + return this.cache.getOrCreateToken(cacheKey, async () => { + const result = await this.appClient.apps.createInstallationAccessToken({ + installation_id: installationId, + headers: HEADERS, + repositories, + }); + return { + token: result.data.token, + expiresAt: DateTime.fromISO(result.data.expires_at), + }; + }); + } + + private async getInstallationData(owner: string): Promise { + // List all installations using the last used etag. + // Return cached InstallationData if error with status 304 is thrown. + try { + this.installations = await this.appClient.apps.listInstallations({ + headers: { + 'If-None-Match': this.installations?.headers.etag, + Accept: HEADERS.Accept, + }, + }); + } catch (error) { + if (error.status !== 304) { + throw error; + } + } + const installation = this.installations?.data.find( + inst => inst.account?.login === owner, + ); + if (installation) { + return { + installationId: installation.id, + suspended: Boolean(installation.suspended_by), + repositorySelection: installation.repository_selection, + }; + } + const notFoundError = new Error( + `No app installation found for ${owner} in ${this.baseAuthConfig.appId}`, + ); + notFoundError.name = 'NotFoundError'; + throw notFoundError; + } +} + +// GithubAppCredentialsMux corresponds to a Github installation which internally could hold several GitHub Apps. +export class GithubAppCredentialsMux { + private readonly apps: GithubAppManager[]; + + constructor(config: GitHubIntegrationConfig) { + this.apps = + config.apps?.map(ac => new GithubAppManager(ac, config.apiBaseUrl)) ?? []; + } + + async getAppToken(owner: string, repo?: string): Promise { + if (this.apps.length === 0) { + return undefined; + } + + const results = await Promise.all( + this.apps.map(app => + app.getInstallationCredentials(owner, repo).then( + credentials => ({ credentials, error: undefined }), + error => ({ credentials: undefined, error }), + ), + ), + ); + + const result = results.find(resultItem => resultItem.credentials); + if (result) { + return result.credentials!.accessToken; + } + + const errors = results.map(r => r.error); + const notNotFoundError = errors.find(err => err.name !== 'NotFoundError'); + if (notNotFoundError) { + throw notNotFoundError; + } + + return undefined; + } +} + +export type GithubCredentials = { + headers?: { [name: string]: string }; + token?: string; +}; + +// TODO: Possibly move this to a backend only package so that it's not used in the frontend by mistake +export class GithubCredentialsProvider { + static create(config: GitHubIntegrationConfig): GithubCredentialsProvider { + return new GithubCredentialsProvider( + new GithubAppCredentialsMux(config), + config.token, + ); + } + + private constructor( + private readonly githubAppCredentialsMux: GithubAppCredentialsMux, + private readonly token?: string, + ) {} + + /** + * Returns GithubCredentials for requested url. + * Consecutive calls to this method with the same url will return cached credentials. + * The shortest lifetime for a token returned is 10 minutes. + * @param opts containing the organization or repository url + * @returns {Promise} of @type {GithubCredentials}. + * @example + * const { token, headers } = await getCredentials({url: 'github.com/backstage/foobar'}) + */ + async getCredentials(opts: { url: string }): Promise { + const parsed = parseGitUrl(opts.url); + + const owner = parsed.owner || parsed.name; + const repo = parsed.owner ? parsed.name : undefined; + + let token = await this.githubAppCredentialsMux.getAppToken(owner, repo); + if (!token) { + token = this.token; + } + + return { + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + token, + }; + } +} diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index 22e8ad57d8..94ed00731e 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -58,6 +58,41 @@ export type GitHubIntegrationConfig = { * If no token is specified, anonymous access is used. */ token?: string; + + /** + * The GitHub Apps configuration to use for requests to this provider. + * + * If no apps are specified, token or anonymous is used. + */ + apps?: GithubAppConfig[]; +}; + +/** + * The configuration parameters for authenticating a GitHub Application. + * A Github Apps configuration can be generated using the `backstage-cli create-github-app` command. + */ +export type GithubAppConfig = { + /** + * Unique app identifier, found at https://github.com/organizations/$org/settings/apps/$AppName + */ + appId: number; + /** + * The private key is used by the GitHub App integration to authenticate the app. + * A private key can be generated from the app at https://github.com/organizations/$org/settings/apps/$AppName + */ + privateKey: string; + /** + * Webhook secret can be configured at https://github.com/organizations/$org/settings/apps/$AppName + */ + webhookSecret: string; + /** + * Found at https://github.com/organizations/$org/settings/apps/$AppName + */ + clientId: string; + /** + * Client secrets can be generated at https://github.com/organizations/$org/settings/apps/$AppName + */ + clientSecret: string; }; /** @@ -72,6 +107,13 @@ export function readGitHubIntegrationConfig( let apiBaseUrl = config.getOptionalString('apiBaseUrl'); let rawBaseUrl = config.getOptionalString('rawBaseUrl'); const token = config.getOptionalString('token'); + const apps = config.getOptionalConfigArray('apps')?.map(c => ({ + appId: c.getNumber('appId'), + clientId: c.getString('clientId'), + clientSecret: c.getString('clientSecret'), + webhookSecret: c.getString('webhookSecret'), + privateKey: c.getString('privateKey'), + })); if (!isValidHost(host)) { throw new Error( @@ -91,7 +133,7 @@ export function readGitHubIntegrationConfig( rawBaseUrl = GITHUB_RAW_BASE_URL; } - return { host, apiBaseUrl, rawBaseUrl, token }; + return { host, apiBaseUrl, rawBaseUrl, token, apps }; } /** diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts index 5f97f6980a..6491e8dcc5 100644 --- a/packages/integration/src/github/index.ts +++ b/packages/integration/src/github/index.ts @@ -20,3 +20,4 @@ export { } from './config'; export type { GitHubIntegrationConfig } from './config'; export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; +export { GithubCredentialsProvider } from './GithubCredentialsProvider'; diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 260afd23d8..8814e33302 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -31,8 +31,9 @@ describe('GitLabIntegration', () => { }, }), }); - expect(integrations.length).toBe(2); // including default - expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('gitlab.com'); }); it('returns the basics', () => { diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index 4d035cb24e..d939917366 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -14,30 +14,35 @@ * limitations under the License. */ -import { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { GitLabIntegrationConfig, readGitLabIntegrationConfigs, } from './config'; export class GitLabIntegration implements ScmIntegration { - static factory: ScmIntegrationFactory = ({ config }) => { + static factory: ScmIntegrationsFactory = ({ config }) => { const configs = readGitLabIntegrationConfigs( config.getOptionalConfigArray('integrations.gitlab') ?? [], ); - return configs.map(integration => ({ - predicate: (url: URL) => url.host === integration.host, - integration: new GitLabIntegration(integration), - })); + return basicIntegrations( + configs.map(c => new GitLabIntegration(c)), + i => i.config.host, + ); }; - constructor(private readonly config: GitLabIntegrationConfig) {} + constructor(private readonly integrationConfig: GitLabIntegrationConfig) {} get type(): string { return 'gitlab'; } get title(): string { - return this.config.host; + return this.integrationConfig.host; + } + + get config(): GitLabIntegrationConfig { + return this.integrationConfig; } } diff --git a/packages/integration/src/gitlab/config.test.ts b/packages/integration/src/gitlab/config.test.ts index e817465b83..303b4bd270 100644 --- a/packages/integration/src/gitlab/config.test.ts +++ b/packages/integration/src/gitlab/config.test.ts @@ -31,11 +31,14 @@ describe('readGitLabIntegrationConfig', () => { buildConfig({ host: 'a.com', token: 't', + baseUrl: 'https://baseurl.for.me/gitlab', }), ); + expect(output).toEqual({ host: 'a.com', token: 't', + baseUrl: 'https://baseurl.for.me/gitlab', }); }); @@ -43,7 +46,20 @@ describe('readGitLabIntegrationConfig', () => { const output = readGitLabIntegrationConfig(buildConfig({})); expect(output).toEqual({ host: 'gitlab.com', - apiBaseUrl: 'gitlab.com/api/v4', + apiBaseUrl: 'https://gitlab.com/api/v4', + baseUrl: 'https://gitlab.com', + }); + }); + + it('injects the correct GitLab API base URL when missing', () => { + const output = readGitLabIntegrationConfig( + buildConfig({ host: 'gitlab.com' }), + ); + + expect(output).toEqual({ + host: 'gitlab.com', + baseUrl: 'https://gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', }); }); @@ -78,6 +94,7 @@ describe('readGitLabIntegrationConfigs', () => { expect(output).toContainEqual({ host: 'a.com', token: 't', + baseUrl: 'https://a.com', }); }); @@ -86,6 +103,7 @@ describe('readGitLabIntegrationConfigs', () => { expect(output).toEqual([ { host: 'gitlab.com', + apiBaseUrl: 'https://gitlab.com/api/v4', }, ]); }); diff --git a/packages/integration/src/gitlab/config.ts b/packages/integration/src/gitlab/config.ts index 2d9f4b39ab..47269b4c32 100644 --- a/packages/integration/src/gitlab/config.ts +++ b/packages/integration/src/gitlab/config.ts @@ -18,7 +18,7 @@ import { Config } from '@backstage/config'; import { isValidHost } from '../helpers'; const GITLAB_HOST = 'gitlab.com'; -const GITLAB_API_BASE_URL = 'gitlab.com/api/v4'; +const GITLAB_API_BASE_URL = 'https://gitlab.com/api/v4'; /** * The configuration parameters for a single GitLab integration. @@ -46,6 +46,14 @@ export type GitLabIntegrationConfig = { * If no token is specified, anonymous access is used. */ token?: string; + + /** + * The baseUrl of this provider, e.g "https://gitlab.com", + * which is passed into the gitlab client. + * + * If no baseUrl is provided, it will default to https://${host} + */ + baseUrl?: string; }; /** @@ -59,6 +67,7 @@ export function readGitLabIntegrationConfig( const host = config.getOptionalString('host') ?? GITLAB_HOST; let apiBaseUrl = config.getOptionalString('apiBaseUrl'); const token = config.getOptionalString('token'); + const baseUrl = config.getOptionalString('baseUrl') ?? `https://${host}`; if (!isValidHost(host)) { throw new Error( @@ -71,7 +80,8 @@ export function readGitLabIntegrationConfig( } else if (host === GITLAB_HOST) { apiBaseUrl = GITLAB_API_BASE_URL; } - return { host, token, apiBaseUrl }; + + return { host, token, apiBaseUrl, baseUrl }; } /** @@ -89,7 +99,7 @@ export function readGitLabIntegrationConfigs( // As a convenience we always make sure there's at least an unauthenticated // reader for public gitlab repos. if (!result.some(c => c.host === GITLAB_HOST)) { - result.push({ host: GITLAB_HOST }); + result.push({ host: GITLAB_HOST, apiBaseUrl: GITLAB_API_BASE_URL }); } return result; diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts index 43fea72e0b..2b4713604c 100644 --- a/packages/integration/src/gitlab/core.test.ts +++ b/packages/integration/src/gitlab/core.test.ts @@ -49,23 +49,23 @@ describe('gitlab core', () => { { config: configWithNoToken, url: - 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', result: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', }, { config: configWithToken, url: - 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', result: - 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', }, { config: configWithNoToken, url: - 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', // Repo not in subgroup result: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', }, // Raw URLs { diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts index 29dcc60bac..66b6733f56 100644 --- a/packages/integration/src/gitlab/core.ts +++ b/packages/integration/src/gitlab/core.ts @@ -109,7 +109,7 @@ export function buildProjectUrl(target: string, projectID: Number): URL { '/api/v4/projects', projectID, 'repository/files', - encodeURIComponent(filePath.join('/')), + encodeURIComponent(decodeURIComponent(filePath.join('/'))), 'raw', ].join('/'); url.search = `?ref=${branch}`; @@ -147,10 +147,15 @@ export async function getProjectId( repoIDLookup.toString(), getGitLabRequestOptions(config), ); - const projectIDJson = await response.json(); - const projectID = Number(projectIDJson.id); + const data = await response.json(); - return projectID; + if (!response.ok) { + throw new Error( + `GitLab Error '${data.error}', ${data.error_description}`, + ); + } + + return Number(data.id); } catch (e) { throw new Error(`Could not get GitLab project ID for: ${target}, ${e}`); } diff --git a/packages/integration/src/helpers.ts b/packages/integration/src/helpers.ts index 02393f99e1..cc1c59a238 100644 --- a/packages/integration/src/helpers.ts +++ b/packages/integration/src/helpers.ts @@ -14,9 +14,29 @@ * limitations under the License. */ +import { ScmIntegration, ScmIntegrationsGroup } from './types'; + /** Checks whether the given url is a valid host */ export function isValidHost(url: string): boolean { const check = new URL('http://example.com'); check.host = url; return check.host === url; } + +export function basicIntegrations( + integrations: T[], + getHost: (integration: T) => string, +): ScmIntegrationsGroup { + return { + list(): T[] { + return integrations; + }, + byUrl(url: string | URL): T | undefined { + const parsed = typeof url === 'string' ? new URL(url) : url; + return integrations.find(i => getHost(i) === parsed.hostname); + }, + byHost(host: string): T | undefined { + return integrations.find(i => getHost(i) === host); + }, + }; +} diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index ae9c360980..7d5b0fbe74 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -15,11 +15,15 @@ */ import { Config } from '@backstage/config'; +import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GitHubIntegration } from './github/GitHubIntegration'; +import { GitLabIntegration } from './gitlab/GitLabIntegration'; /** * Encapsulates a single SCM integration. */ -export type ScmIntegration = { +export interface ScmIntegration { /** * The type of integration, e.g. "github". */ @@ -30,30 +34,64 @@ export type ScmIntegration = { * differentiate between different integrations. */ title: string; -}; + + /** + * Works like the two-argument form of the URL constructor, resolving an + * absolute or relative URL in relation to a base URL. + * + * If this method is not implemented, the URL constructor is used instead for + * URLs that match this integration. + * + * @param options.url The (absolute or relative) URL or path to resolve + * @param options.base The base URL onto which this resolution happens + */ + resolveUrl?(options: { url: string; base: string }): string; +} /** - * Holds all registered SCM integrations. + * Encapsulates several integrations, that are all of the same type. */ -export type ScmIntegrationRegistry = { +export interface ScmIntegrationsGroup { /** - * Lists all registered integrations. + * Lists all registered integrations of this type. */ - list(): ScmIntegration[]; + list(): T[]; /** - * Fetches an integration by URL. + * Fetches an integration of this type by URL. * - * @param url A URL that matches a registered integration + * @param url A URL that matches a registered integration of this type */ - byUrl(url: string): ScmIntegration | undefined; -}; + byUrl(url: string | URL): T | undefined; -export type ScmIntegrationPredicateTuple = { - predicate: (url: URL) => boolean; - integration: ScmIntegration; -}; + /** + * Fetches an integration of this type by host name. + * + * @param url A host name that matches a registered integration of this type + */ + byHost(host: string): T | undefined; +} -export type ScmIntegrationFactory = (options: { +/** + * Holds all registered SCM integrations, of all types. + */ +export interface ScmIntegrationRegistry + extends ScmIntegrationsGroup { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; + + /** + * Works like the two-argument form of the URL constructor, resolving an + * absolute or relative URL in relation to a base URL. + * + * @param options.url The (absolute or relative) URL or path to resolve + * @param options.base The base URL onto which this resolution happens + */ + resolveUrl(options: { url: string; base: string }): string; +} + +export type ScmIntegrationsFactory = (options: { config: Config; -}) => ScmIntegrationPredicateTuple[]; +}) => ScmIntegrationsGroup; diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 1672e5a61b..baf7a380a2 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,113 @@ # @backstage/techdocs-common +## 0.3.7 + +### Patch Changes + +- c777df180: 1. Added option to use Azure Blob Storage as a choice to store the static generated files for TechDocs. +- e44925723: `techdocs.requestUrl` and `techdocs.storageUrl` are now optional configs and the discovery API will be used to get the URL where techdocs plugin is hosted. +- f0320190d: dir preparer will use URL Reader in its implementation. +- Updated dependencies [c4abcdb60] +- Updated dependencies [2430ee7c2] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [064c513e1] +- Updated dependencies [7881f2117] +- Updated dependencies [3149bfe63] +- Updated dependencies [2e62aea6f] +- Updated dependencies [11cb5ef94] + - @backstage/integration@0.3.2 + - @backstage/backend-common@0.5.2 + - @backstage/catalog-model@0.7.1 + +## 0.3.6 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- db2328c88: Add rate limiter for concurrent execution of file uploads in AWS and Google publishers +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + - @backstage/integration@0.3.1 + +## 0.3.5 + +### Patch Changes + +- 53c9c51f2: TechDocs backend now streams files through from Google Cloud Storage to the browser, improving memory usage. +- a5e27d5c1: Create type for TechDocsMetadata (#3716) + + This change introduces a new type (TechDocsMetadata) in packages/techdocs-common. This type is then introduced in the endpoint response in techdocs-backend and in the api interface in techdocs (frontend). + +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [fa8ba330a] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [ed6baab66] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + - @backstage/integration@0.3.0 + +## 0.3.4 + +### Patch Changes + +- a594a7257: @backstage/techdocs-common can now be imported in an environment without @backstage/plugin-techdocs-backend being installed. + +## 0.3.3 + +### Patch Changes + +- 68ad5af51: Improve techdocs-common Generator API for it to be used by techdocs-cli. TechDocs generator.run function now takes + an input AND an output directory. Most probably you use techdocs-common via plugin-techdocs-backend, and so there + is no breaking change for you. + But if you use techdocs-common separately, you need to create an output directory and pass into the generator. +- 371f67ecd: fix to-string breakage of binary files +- f1e74777a: Fix bug where binary files (`png`, etc.) could not load when using AWS or GCS publisher. +- dbe4450c3: Google Cloud authentication in TechDocs has been improved. + + 1. `techdocs.publisher.googleGcs.credentials` is now optional. If it is missing, `GOOGLE_APPLICATION_CREDENTIALS` + environment variable (and some other methods) will be used to authenticate. + Read more here https://cloud.google.com/docs/authentication/production + + 2. `techdocs.publisher.googleGcs.projectId` is no longer used. You can remove it from your `app-config.yaml`. + +- 5826d0973: AWS SDK version bump for TechDocs. +- b3b9445df: AWS S3 authentication in TechDocs has been improved. + + 1. `techdocs.publisher.awsS3.bucketName` is now the only required config. `techdocs.publisher.awsS3.credentials` and `techdocs.publisher.awsS3.region` are optional. + + 2. If `techdocs.publisher.awsS3.credentials` and `techdocs.publisher.awsS3.region` are missing, the AWS environment variables `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION` will be used. There are more better ways of setting up AWS authentication. Read the guide at https://backstage.io/docs/features/techdocs/using-cloud-storage + +- Updated dependencies [466354aaa] +- Updated dependencies [f3b064e1c] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/integration@0.2.0 + - @backstage/catalog-model@0.6.1 + - @backstage/backend-common@0.4.3 + +## 0.3.2 + +### Patch Changes + +- 7ec525481: 1. Added option to use AWS S3 as a choice to store the static generated files for TechDocs. +- f8ba88ded: Fix for `integration.github.apiBaseUrl` configuration not properly overriding apiBaseUrl used by techdocs +- 00042e73c: Moving the Git actions to isomorphic-git instead of the node binding version of nodegit +- Updated dependencies [5ecd50f8a] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/backend-common@0.4.2 + - @backstage/integration@0.1.5 + ## 0.3.1 ### Patch Changes diff --git a/packages/techdocs-common/__mocks__/aws-sdk.ts b/packages/techdocs-common/__mocks__/@aws-sdk/client-s3.ts similarity index 52% rename from packages/techdocs-common/__mocks__/aws-sdk.ts rename to packages/techdocs-common/__mocks__/@aws-sdk/client-s3.ts index 987b3d2800..2d2581a6cd 100644 --- a/packages/techdocs-common/__mocks__/aws-sdk.ts +++ b/packages/techdocs-common/__mocks__/@aws-sdk/client-s3.ts @@ -13,74 +13,49 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { S3 as S3Types } from 'aws-sdk'; +import type { S3ClientConfig } from '@aws-sdk/client-s3'; import { EventEmitter } from 'events'; import fs from 'fs'; export class S3 { private readonly options; - constructor(options: S3Types.ClientConfiguration) { + constructor(options: S3ClientConfig) { this.options = options; } headObject({ Key }: { Key: string }) { - return { - promise: () => this.checkFileExists(Key), - }; - } - - getObject({ Key }: { Key: string }) { - return { - promise: () => this.checkFileExists(Key), - createReadStream: () => { - const emitter = new EventEmitter(); - process.nextTick(() => { - if (fs.existsSync(Key)) { - emitter.emit('data', Buffer.from(fs.readFileSync(Key))); - } else { - emitter.emit( - 'error', - new Error(`The file ${Key} doest not exist !`), - ); - } - emitter.emit('end'); - }); - return emitter; - }, - }; - } - - checkFileExists(Key: string) { return new Promise((resolve, reject) => { if (fs.existsSync(Key)) { resolve(''); } else { - reject({ message: 'The object doest not exist !' }); + reject({ message: `The file ${Key} doest not exist.` }); + } + }); + } + + getObject({ Key }: { Key: string }) { + return new Promise((resolve, reject) => { + if (fs.existsSync(Key)) { + const emitter = new EventEmitter(); + process.nextTick(() => { + emitter.emit('data', Buffer.from(fs.readFileSync(Key))); + emitter.emit('end'); + }); + resolve({ + Body: emitter, + }); + } else { + reject({ message: `The file ${Key} doest not exist.` }); } }); } headBucket() { - return new Promise(resolve => { - resolve(''); - }); + return ''; } - upload({ Key }: { Key: string }) { - return { - promise: () => - new Promise((resolve, reject) => { - if (!fs.existsSync(Key)) { - reject(''); - } else { - resolve(''); - } - }), - }; + putObject() { + return ''; } } - -export default { - S3, -}; diff --git a/packages/techdocs-common/__mocks__/@azure/identity.ts b/packages/techdocs-common/__mocks__/@azure/identity.ts new file mode 100644 index 0000000000..cc89a4d514 --- /dev/null +++ b/packages/techdocs-common/__mocks__/@azure/identity.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 class DefaultAzureCredential { + /** + * Creates an instance of the DefaultAzureCredential class. + */ +} diff --git a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts new file mode 100644 index 0000000000..60a705f6a7 --- /dev/null +++ b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts @@ -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 fs from 'fs'; + +export class BlockBlobClient { + private readonly blobName; + + constructor(blobName: string) { + this.blobName = blobName; + } + + uploadFile(source: string) { + return new Promise((resolve, reject) => { + if (!fs.existsSync(source)) { + reject(''); + } else { + resolve(''); + } + }); + } + + exists() { + return new Promise((resolve, reject) => { + if (fs.existsSync(this.blobName)) { + resolve(true); + } else { + reject({ message: 'The object doest not exist !' }); + } + }); + } +} + +export class ContainerClient { + private readonly containerName; + + constructor(containerName: string) { + this.containerName = containerName; + } + + getProperties() { + return new Promise(resolve => { + resolve(''); + }); + } + + getBlockBlobClient(blobName: string) { + return new BlockBlobClient(blobName); + } +} + +export class BlobServiceClient { + private readonly url; + private readonly credential; + + constructor(url: string, credential?: StorageSharedKeyCredential) { + this.url = url; + this.credential = credential; + } + + getContainerClient(containerName: string) { + return new ContainerClient(containerName); + } +} + +export class StorageSharedKeyCredential { + private readonly accountName; + private readonly accountKey; + + constructor(accountName: string, accountKey: string) { + this.accountName = accountName; + this.accountKey = accountKey; + } +} diff --git a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts index e95cee11d0..b84018c089 100644 --- a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts +++ b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts @@ -14,7 +14,6 @@ * limitations under the License. */ type storageOptions = { - projectId?: string; keyFilename?: string; }; @@ -39,11 +38,9 @@ class Bucket { } export class Storage { - private readonly projectId; private readonly keyFilename; constructor(options: storageOptions) { - this.projectId = options.projectId; this.keyFilename = options.keyFilename; } diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 7ddb0b7dff..9b0f7fbf9f 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.3.1", + "version": "0.3.7", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -36,27 +36,32 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@aws-sdk/client-s3": "^3.1.0", + "@azure/identity": "^1.2.2", + "@azure/storage-blob": "^12.4.0", + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.1.4", + "@backstage/integration": "^0.3.2", "@google-cloud/storage": "^5.6.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", - "aws-sdk": "^2.817.0", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", "fs-extra": "^9.0.1", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "js-yaml": "^4.0.0", + "json5": "^2.1.3", "mime-types": "^2.1.27", "mock-fs": "^4.13.0", + "p-limit": "^3.1.0", "recursive-readdir": "^2.2.2", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@aws-sdk/types": "3.1.0", + "@backstage/cli": "^0.6.0", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", "@types/js-yaml": "^3.12.5", diff --git a/packages/techdocs-common/src/helpers.test.ts b/packages/techdocs-common/src/helpers.test.ts index 88c4d8829e..eba1386b11 100644 --- a/packages/techdocs-common/src/helpers.test.ts +++ b/packages/techdocs-common/src/helpers.test.ts @@ -14,14 +14,18 @@ * limitations under the License. */ +import { + ReadTreeResponse, + SearchResponse, + UrlReader, +} from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; import { Readable } from 'stream'; import { getDocFilesFromRepository, getLocationForEntity, parseReferenceAnnotation, } from './helpers'; -import { UrlReader, ReadTreeResponse } from '@backstage/backend-common'; -import { Entity } from '@backstage/catalog-model'; const entityBase: Entity = { metadata: { @@ -135,6 +139,14 @@ describe('getDocFilesFromRepository', () => { archive: async () => { return Readable.from(''); }, + etag: '', + }; + } + + async search(): Promise { + return { + etag: '', + files: [], }; } } diff --git a/packages/techdocs-common/src/stages/generate/helpers.test.ts b/packages/techdocs-common/src/stages/generate/helpers.test.ts index 48a48986f2..4d62648a7d 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.test.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.test.ts @@ -80,14 +80,14 @@ describe('helpers', () => { const imageName = 'spotify/techdocs'; const args = ['build', '-d', '/result']; const docsDir = os.tmpdir(); - const resultDir = os.tmpdir(); + const outputDir = os.tmpdir(); it('should pull the techdocs docker container', async () => { await runDockerContainer({ imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }); @@ -103,7 +103,7 @@ describe('helpers', () => { imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }); @@ -118,7 +118,7 @@ describe('helpers', () => { }, WorkingDir: '/content', HostConfig: { - Binds: [`${docsDir}:/content`, `${resultDir}:/result`], + Binds: [`${docsDir}:/content`, `${outputDir}:/result`], }, }, ); @@ -129,7 +129,7 @@ describe('helpers', () => { imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }); @@ -151,7 +151,7 @@ describe('helpers', () => { imageName, args, docsDir, - resultDir, + outputDir, dockerClient: mockDocker, }), ).rejects.toThrow(new RegExp(`.+: ${dockerError}`)); diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts index 16f5b26632..e396157b92 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -39,7 +39,7 @@ type RunDockerContainerOptions = { args: string[]; logStream?: Writable; docsDir: string; - resultDir: string; + outputDir: string; dockerClient: Docker; createOptions?: Docker.ContainerCreateOptions; }; @@ -56,7 +56,7 @@ export async function runDockerContainer({ args, logStream = new PassThrough(), docsDir, - resultDir, + outputDir, dockerClient, createOptions, }: RunDockerContainerOptions) { @@ -89,7 +89,7 @@ export async function runDockerContainer({ }, WorkingDir: '/content', HostConfig: { - Binds: [`${docsDir}:/content`, `${resultDir}:/result`], + Binds: [`${docsDir}:/content`, `${outputDir}:/result`], }, ...createOptions, }, diff --git a/packages/techdocs-common/src/stages/generate/techdocs.ts b/packages/techdocs-common/src/stages/generate/techdocs.ts index faefd8ac16..507b9da6b8 100644 --- a/packages/techdocs-common/src/stages/generate/techdocs.ts +++ b/packages/techdocs-common/src/stages/generate/techdocs.ts @@ -14,18 +14,12 @@ * limitations under the License. */ -import fs from 'fs-extra'; import path from 'path'; -import os from 'os'; import { Logger } from 'winston'; import { PassThrough } from 'stream'; import { Config } from '@backstage/config'; -import { - GeneratorBase, - GeneratorRunOptions, - GeneratorRunResult, -} from './types'; +import { GeneratorBase, GeneratorRunOptions } from './types'; import { runDockerContainer, runCommand, @@ -64,40 +58,37 @@ export class TechdocsGenerator implements GeneratorBase { } public async run({ - directory, + inputDir, + outputDir, dockerClient, parsedLocationAnnotation, - }: GeneratorRunOptions): Promise { - const tmpdirPath = os.tmpdir(); - // Fixes a problem with macOS returning a path that is a symlink - const tmpdirResolvedPath = fs.realpathSync(tmpdirPath); - const resultDir = fs.mkdtempSync( - path.join(tmpdirResolvedPath, 'techdocs-tmp-'), - ); + }: GeneratorRunOptions): Promise { const [log, logStream] = createStream(); // TODO: In future mkdocs.yml can be mkdocs.yaml. So, use a config variable here to find out // the correct file name. // Do some updates to mkdocs.yml before generating docs e.g. adding repo_url - await patchMkdocsYmlPreBuild( - path.join(directory, 'mkdocs.yml'), - this.logger, - parsedLocationAnnotation, - ); + if (parsedLocationAnnotation) { + await patchMkdocsYmlPreBuild( + path.join(inputDir, 'mkdocs.yml'), + this.logger, + parsedLocationAnnotation, + ); + } try { switch (this.options.runGeneratorIn) { case 'local': await runCommand({ command: 'mkdocs', - args: ['build', '-d', resultDir, '-v'], + args: ['build', '-d', outputDir, '-v'], options: { - cwd: directory, + cwd: inputDir, }, logStream, }); this.logger.info( - `Successfully generated docs from ${directory} into ${resultDir} using local mkdocs`, + `Successfully generated docs from ${inputDir} into ${outputDir} using local mkdocs`, ); break; case 'docker': @@ -105,12 +96,12 @@ export class TechdocsGenerator implements GeneratorBase { imageName: 'spotify/techdocs', args: ['build', '-d', '/result'], logStream, - docsDir: directory, - resultDir, + docsDir: inputDir, + outputDir, dockerClient, }); this.logger.info( - `Successfully generated docs from ${directory} into ${resultDir} using techdocs-container`, + `Successfully generated docs from ${inputDir} into ${outputDir} using techdocs-container`, ); break; default: @@ -120,14 +111,12 @@ export class TechdocsGenerator implements GeneratorBase { } } catch (error) { this.logger.debug( - `Failed to generate docs from ${directory} into ${resultDir}`, + `Failed to generate docs from ${inputDir} into ${outputDir}`, ); this.logger.debug(`Build failed with error: ${log}`); throw new Error( - `Failed to generate docs from ${directory} into ${resultDir} with error ${error.message}`, + `Failed to generate docs from ${inputDir} into ${outputDir} with error ${error.message}`, ); } - - return { resultDir }; } } diff --git a/packages/techdocs-common/src/stages/generate/types.ts b/packages/techdocs-common/src/stages/generate/types.ts index 65e411c3aa..7724107dac 100644 --- a/packages/techdocs-common/src/stages/generate/types.ts +++ b/packages/techdocs-common/src/stages/generate/types.ts @@ -18,32 +18,26 @@ import Docker from 'dockerode'; import { Entity } from '@backstage/catalog-model'; import { ParsedLocationAnnotation } from '../../helpers'; -/** - * The returned directory from the generator which is ready - * to pass to the next stage of the TechDocs which is publishing - */ -export type GeneratorRunResult = { - resultDir: string; -}; - /** * The values that the generator will receive. * - * @param {string} directory The directory of the uncompiled documentation, with the values from the frontend + * @param {string} inputDir The directory of the uncompiled documentation, with the values from the frontend + * @param {string} outputDir Directory to store generated docs in. Usually - a newly created temporary directory. * @param {Docker} dockerClient A docker client to run any generator on top of your directory * @param {ParsedLocationAnnotation} parsedLocationAnnotation backstage.io/techdocs-ref annotation of an entity * @param {Writable} [logStream] A dedicated log stream */ export type GeneratorRunOptions = { - directory: string; + inputDir: string; + outputDir: string; dockerClient: Docker; - parsedLocationAnnotation: ParsedLocationAnnotation; + parsedLocationAnnotation?: ParsedLocationAnnotation; logStream?: Writable; }; export type GeneratorBase = { - // runs the generator with the values and returns the directory to be published - run(opts: GeneratorRunOptions): Promise; + // Runs the generator with the values + run(opts: GeneratorRunOptions): Promise; }; /** diff --git a/packages/techdocs-common/src/stages/prepare/dir.test.ts b/packages/techdocs-common/src/stages/prepare/dir.test.ts index d082b1367b..cd8d358afe 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.test.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.test.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { getVoidLogger, UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { DirectoryPreparer } from './dir'; import { checkoutGitRepository } from '../../helpers'; @@ -46,10 +46,19 @@ const createMockEntity = (annotations: {}) => { }; const mockConfig = new ConfigReader({}); +const mockUrlReader: jest.Mocked = { + read: jest.fn(), + readTree: jest.fn(), + search: jest.fn(), +}; describe('directory preparer', () => { it('should merge managed-by-location and techdocs-ref when techdocs-ref is relative', async () => { - const directoryPreparer = new DirectoryPreparer(mockConfig, logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -63,7 +72,11 @@ describe('directory preparer', () => { }); it('should merge managed-by-location and techdocs-ref when techdocs-ref is absolute', async () => { - const directoryPreparer = new DirectoryPreparer(mockConfig, logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -77,7 +90,11 @@ describe('directory preparer', () => { }); it('should merge managed-by-location and techdocs-ref when managed-by-location is a git repository', async () => { - const directoryPreparer = new DirectoryPreparer(mockConfig, logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': diff --git a/packages/techdocs-common/src/stages/prepare/dir.ts b/packages/techdocs-common/src/stages/prepare/dir.ts index 537f670edc..ab277fda9a 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.ts @@ -18,17 +18,19 @@ import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import path from 'path'; import { parseReferenceAnnotation, checkoutGitRepository } from '../../helpers'; -import { InputError } from '@backstage/backend-common'; +import { UrlReader, InputError } from '@backstage/backend-common'; import parseGitUrl from 'git-url-parse'; import { Logger } from 'winston'; export class DirectoryPreparer implements PreparerBase { - private readonly config: Config; - private readonly logger: Logger; - - constructor(config: Config, logger: Logger) { + constructor( + private readonly config: Config, + private readonly logger: Logger, + private readonly reader: UrlReader, + ) { this.config = config; this.logger = logger; + this.reader = reader; } private async resolveManagedByLocationToDir(entity: Entity) { @@ -41,9 +43,12 @@ export class DirectoryPreparer implements PreparerBase { `Building docs for entity with type 'dir' and managed-by-location '${type}'`, ); switch (type) { + case 'url': { + const response = await this.reader.readTree(target); + return await response.dir(); + } case 'github': case 'gitlab': - case 'url': case 'azure/api': { const parsedGitLocation = parseGitUrl(target); const repoLocation = await checkoutGitRepository( @@ -56,7 +61,6 @@ export class DirectoryPreparer implements PreparerBase { path.join(repoLocation, parsedGitLocation.filepath), ); } - case 'file': return path.dirname(target); default: diff --git a/packages/techdocs-common/src/stages/prepare/index.ts b/packages/techdocs-common/src/stages/prepare/index.ts index 6c73725a3a..dcb178bafa 100644 --- a/packages/techdocs-common/src/stages/prepare/index.ts +++ b/packages/techdocs-common/src/stages/prepare/index.ts @@ -17,4 +17,4 @@ export { DirectoryPreparer } from './dir'; export { CommonGitPreparer } from './commonGit'; export { UrlPreparer } from './url'; export { Preparers } from './preparers'; -export type { PreparerBuilder, PreparerBase } from './types'; +export type { PreparerBuilder, PreparerBase, RemoteProtocol } from './types'; diff --git a/packages/techdocs-common/src/stages/prepare/preparers.ts b/packages/techdocs-common/src/stages/prepare/preparers.ts index 4625d7b0e4..de825d5d41 100644 --- a/packages/techdocs-common/src/stages/prepare/preparers.ts +++ b/packages/techdocs-common/src/stages/prepare/preparers.ts @@ -35,17 +35,22 @@ export class Preparers implements PreparerBuilder { ): Promise { const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(config, logger); + const urlPreparer = new UrlPreparer(reader, logger); + preparers.register('url', urlPreparer); + + /** + * Dir preparer is a syntactic sugar for users to define techdocs-ref annotation. + * When using dir preparer, the docs will be fetched using URL Reader. + */ + const directoryPreparer = new DirectoryPreparer(config, logger, reader); preparers.register('dir', directoryPreparer); + // Common git preparers will be deprecated soon. const commonGitPreparer = new CommonGitPreparer(config, logger); preparers.register('github', commonGitPreparer); preparers.register('gitlab', commonGitPreparer); preparers.register('azure/api', commonGitPreparer); - const urlPreparer = new UrlPreparer(reader, logger); - preparers.register('url', urlPreparer); - return preparers; } diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts old mode 100644 new mode 100755 index a1d52be85b..3902aab21a --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -18,7 +18,7 @@ import path from 'path'; import * as winston from 'winston'; import { ConfigReader } from '@backstage/config'; import { AwsS3Publish } from './awsS3'; -import { PublisherBase } from './types'; +import { PublisherBase, TechDocsMetadata } from './types'; import type { Entity, EntityName } from '@backstage/catalog-model'; const createMockEntity = (annotations = {}): Entity => { @@ -102,12 +102,6 @@ describe('AwsS3Publish', () => { }); it('should fail to publish a directory', async () => { - const wrongPathToGeneratedDirectory = path.join( - 'wrong', - 'path', - 'to', - 'generatedDirectory', - ); const entity = createMockEntity(); const entityRootDir = getEntityRootDir(entity); @@ -124,13 +118,11 @@ describe('AwsS3Publish', () => { await publisher .publish({ entity, - directory: wrongPathToGeneratedDirectory, + directory: '/wrong/path/to/generatedDirectory', }) .catch(error => - expect(error).toEqual( - new Error( - `Unable to upload file(s) to AWS S3. Error Failed to read template directory: ENOENT, no such file or directory '${wrongPathToGeneratedDirectory}'`, - ), + expect(error.message).toContain( + 'Unable to upload file(s) to AWS S3. Error Failed to read template directory', ), ); mockFs.restore(); @@ -167,27 +159,56 @@ describe('AwsS3Publish', () => { mockFs({ [entityRootDir]: { - 'techdocs_metadata.json': 'file-content', + 'techdocs_metadata.json': + '{"site_name": "backstage", "site_description": "site_content"}', }, }); - expect(await publisher.fetchTechDocsMetadata(entityNameMock)).toBe( - 'file-content', - ); + const expectedMetadata: TechDocsMetadata = { + site_name: 'backstage', + site_description: 'site_content', + }; + expect( + await publisher.fetchTechDocsMetadata(entityNameMock), + ).toStrictEqual(expectedMetadata); + mockFs.restore(); + }); + + it('should return tech docs metadata when json encoded with single quotes', async () => { + const entityNameMock = createMockEntityName(); + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content'}`, + }, + }); + + const expectedMetadata: TechDocsMetadata = { + site_name: 'backstage', + site_description: 'site_content', + }; + expect( + await publisher.fetchTechDocsMetadata(entityNameMock), + ).toStrictEqual(expectedMetadata); mockFs.restore(); }); it('should return an error if the techdocs_metadata.json file is not present', async () => { const entityNameMock = createMockEntityName(); const entity = createMockEntity(); - const entityRootDir = getEntityRootDir(entity); + const { + metadata: { name, namespace }, + kind, + } = entity; await publisher .fetchTechDocsMetadata(entityNameMock) .catch(error => expect(error).toEqual( new Error( - `TechDocs metadata fetch failed, The file ${entityRootDir}/techdocs_metadata.json doest not exist !`, + `TechDocs metadata fetch failed, The file ${namespace}/${kind}/${name}/techdocs_metadata.json doest not exist.`, ), ), ); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 15ff33408a..20e6bc424d 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -15,41 +15,74 @@ */ import path from 'path'; import express from 'express'; -import aws from 'aws-sdk'; +import { PutObjectCommandOutput, S3 } from '@aws-sdk/client-s3'; import { Logger } from 'winston'; import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; -import { PublisherBase, PublishRequest } from './types'; -import { ManagedUpload } from 'aws-sdk/clients/s3'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; import fs from 'fs-extra'; +import { Readable } from 'stream'; +import JSON5 from 'json5'; +import createLimiter from 'p-limit'; + +const streamToBuffer = (stream: Readable): Promise => { + return new Promise((resolve, reject) => { + try { + const chunks: any[] = []; + stream.on('data', chunk => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + } catch (e) { + throw new Error(`Unable to parse the response data ${e.message}`); + } + }); +}; export class AwsS3Publish implements PublisherBase { static fromConfig(config: Config, logger: Logger): PublisherBase { - let region = null; - let accessKeyId = null; - let secretAccessKey = null; let bucketName = ''; try { - accessKeyId = config.getString( - 'techdocs.publisher.awsS3.credentials.accessKeyId', - ); - secretAccessKey = config.getString( - 'techdocs.publisher.awsS3.credentials.secretAccessKey', - ); - region = config.getOptionalString('techdocs.publisher.awsS3.region'); bucketName = config.getString('techdocs.publisher.awsS3.bucketName'); } catch (error) { throw new Error( "Since techdocs.publisher.type is set to 'awsS3' in your app config, " + - 'credentials and bucketName are required in techdocs.publisher.awsS3 ' + - 'required to authenticate with AWS S3.', + 'techdocs.publisher.awsS3.bucketName is required.', ); } - const storageClient = new aws.S3({ - credentials: { accessKeyId, secretAccessKey }, - ...(region && { region }), + // Credentials is an optional config. If missing, default AWS environment variables + // or AWS shared credentials file at ~/.aws/credentials will be used to authenticate + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html + const credentials = config.getOptionalConfig( + 'techdocs.publisher.awsS3.credentials', + ); + let accessKeyId = undefined; + let secretAccessKey = undefined; + if (credentials) { + accessKeyId = credentials.getOptionalString('accessKeyId'); + secretAccessKey = credentials.getOptionalString('secretAccessKey'); + } + + // AWS Region is an optional config. If missing, default AWS env variable AWS_REGION + // or AWS shared credentials file at ~/.aws/credentials will be used. Any way, AWS SDK v3 client needs + // to have the AWS Region information for it to work. + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html + const region = config.getOptionalString('techdocs.publisher.awsS3.region'); + + const storageClient = new S3({ + ...(credentials && + accessKeyId && + secretAccessKey && { + credentials: { + accessKeyId, + secretAccessKey, + }, + }), + ...(region && { + region, + }), }); // Check if the defined bucket exists. Being able to connect means the configuration is good @@ -62,11 +95,12 @@ export class AwsS3Publish implements PublisherBase { if (err) { logger.error( `Could not retrieve metadata about the AWS S3 bucket ${bucketName}. ` + - 'Make sure the AWS project and the bucket exists and the access key located at the path ' + - "techdocs.publisher.awsS3.credentials defined in app config has the role 'Storage Object Creator'. " + - 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + 'Make sure the bucket exists. Also make sure that authentication is setup either by ' + + 'explicitly defining credentials and region in techdocs.publisher.awsS3 in app config or ' + + 'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', ); - throw new Error(`from AWS client library: ${err.message}`); + logger.error(`from AWS client library: ${err.message}`); + throw new Error(); } else { logger.info( `Successfully connected to the AWS S3 bucket ${bucketName}.`, @@ -79,7 +113,7 @@ export class AwsS3Publish implements PublisherBase { } constructor( - private readonly storageClient: aws.S3, + private readonly storageClient: S3, private readonly bucketName: string, private readonly logger: Logger, ) { @@ -98,8 +132,8 @@ export class AwsS3Publish implements PublisherBase { // So collecting path of only the files is good enough. const allFilesToUpload = await getFileTreeRecursively(directory); - const uploadPromises: Array> = []; - + const limiter = createLimiter(10); + const uploadPromises: Array> = []; for (const filePath of allFilesToUpload) { // Remove the absolute path prefix of the source directory // Path of all files to upload, relative to the root of the source directory @@ -116,7 +150,9 @@ export class AwsS3Publish implements PublisherBase { Body: fileContent, }; - uploadPromises.push(this.storageClient.upload(params).promise()); + // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) + const uploadFile = limiter(() => this.storageClient.putObject(params)); + uploadPromises.push(uploadFile); } await Promise.all(uploadPromises); this.logger.info( @@ -130,30 +166,37 @@ export class AwsS3Publish implements PublisherBase { } } - async fetchTechDocsMetadata(entityName: EntityName): Promise { + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { try { - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; - const fileStreamChunks: Array = []; this.storageClient .getObject({ Bucket: this.bucketName, Key: `${entityRootDir}/techdocs_metadata.json`, }) - .createReadStream() - .on('error', err => { + .then(async file => { + const techdocsMetadataJson = await streamToBuffer( + file.Body as Readable, + ); + + if (!techdocsMetadataJson) { + throw new Error( + `Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`, + ); + } + const techdocsMetadata = JSON5.parse( + techdocsMetadataJson.toString('utf-8'), + ); + + resolve(techdocsMetadata); + }) + .catch(err => { this.logger.error(err.message); reject(new Error(err.message)); - }) - .on('data', chunk => { - fileStreamChunks.push(chunk); - }) - .on('end', () => { - const techdocsMetadataJson = Buffer.concat( - fileStreamChunks, - ).toString(); - resolve(techdocsMetadataJson); }); }); } catch (e) { @@ -174,19 +217,14 @@ export class AwsS3Publish implements PublisherBase { const fileExtension = path.extname(filePath); const responseHeaders = getHeadersForFileExtension(fileExtension); - const fileStreamChunks: Array = []; this.storageClient .getObject({ Bucket: this.bucketName, Key: filePath }) - .createReadStream() - .on('error', err => { - this.logger.warn(err.message); - res.status(404).send(err.message); - }) - .on('data', chunk => { - fileStreamChunks.push(chunk); - }) - .on('end', () => { - const fileContent = Buffer.concat(fileStreamChunks).toString(); + .then(async object => { + const fileContent = await streamToBuffer(object.Body as Readable); + if (!fileContent) { + throw new Error(`Unable to parse the file ${filePath}.`); + } + // Inject response headers for (const [headerKey, headerValue] of Object.entries( responseHeaders, @@ -195,6 +233,10 @@ export class AwsS3Publish implements PublisherBase { } res.send(fileContent); + }) + .catch(err => { + this.logger.warn(err.message); + res.status(404).send(err.message); }); }; } @@ -206,12 +248,10 @@ export class AwsS3Publish implements PublisherBase { async hasDocsBeenGenerated(entity: Entity): Promise { try { const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; - await this.storageClient - .headObject({ - Bucket: this.bucketName, - Key: `${entityRootDir}/index.html`, - }) - .promise(); + await this.storageClient.headObject({ + Bucket: this.bucketName, + Key: `${entityRootDir}/index.html`, + }); return Promise.resolve(true); } catch (e) { return Promise.resolve(false); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts new file mode 100644 index 0000000000..80f3cb0cf7 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.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 mockFs from 'mock-fs'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; +import { PublisherBase } from './types'; +import type { Entity } from '@backstage/catalog-model'; + +const createMockEntity = (annotations = {}) => { + return { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'test-component-name', + namespace: 'test-namespace', + annotations: { + ...annotations, + }, + }, + }; +}; + +const getEntityRootDir = (entity: Entity) => { + const { + kind, + metadata: { namespace, name }, + } = entity; + const entityRootDir = `${namespace}/${kind}/${name}`; + return entityRootDir; +}; + +const logger = getVoidLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); +jest.spyOn(logger, 'error').mockReturnValue(logger); + +let publisher: PublisherBase; + +beforeEach(async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); +}); + +describe('AzureBlobStoragePublish', () => { + describe('publish', () => { + it('should publish a directory', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + expect( + await publisher.publish({ + entity, + directory: entityRootDir, + }), + ).toBeUndefined(); + mockFs.restore(); + }); + + it('should fail to publish a directory', async () => { + const wrongPathToGeneratedDirectory = 'wrong/path/to/generatedDirectory'; + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + await publisher + .publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }) + .catch(error => + expect(error.message).toContain( + 'Unable to upload file(s) to Azure Blob Storage. Error Failed to read template directory: ENOENT, no such file or directory', + ), + ); + mockFs.restore(); + }); + }); + + describe('hasDocsBeenGenerated', () => { + it('should return true if docs has been generated', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': 'file-content', + }, + }); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); + mockFs.restore(); + }); + + it('should return false if docs has not been generated', async () => { + const entity = createMockEntity(); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false); + }); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts new file mode 100644 index 0000000000..a02639e63f --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -0,0 +1,259 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import platformPath from 'path'; +import express from 'express'; +import { + BlobServiceClient, + BlobUploadCommonResponse, + StorageSharedKeyCredential, +} from '@azure/storage-blob'; +import { DefaultAzureCredential } from '@azure/identity'; +import { Logger } from 'winston'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import limiterFactory from 'p-limit'; +import JSON5 from 'json5'; + +// The number of batches that may be ongoing at the same time. +const BATCH_CONCURRENCY = 3; + +export class AzureBlobStoragePublish implements PublisherBase { + static async fromConfig( + config: Config, + logger: Logger, + ): Promise { + let containerName = ''; + try { + containerName = config.getString( + 'techdocs.publisher.azureBlobStorage.containerName', + ); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " + + 'techdocs.publisher.azureBlobStorage.containerName is required.', + ); + } + + let accountName = ''; + try { + accountName = config.getString( + 'techdocs.publisher.azureBlobStorage.credentials.accountName', + ); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " + + 'techdocs.publisher.azureBlobStorage.credentials.accountName is required.', + ); + } + + // Credentials is an optional config. If missing, default Azure Blob Storage environment variables will be used. + // https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad-app + const accountKey = config.getOptionalString( + 'techdocs.publisher.azureBlobStorage.credentials.accountKey', + ); + + let credential; + if (accountKey) { + credential = new StorageSharedKeyCredential(accountName, accountKey); + } else { + credential = new DefaultAzureCredential(); + } + + const storageClient = new BlobServiceClient( + `https://${accountName}.blob.core.windows.net`, + credential, + ); + + await storageClient + .getContainerClient(containerName) + .getProperties() + .then(() => { + logger.info( + `Successfully connected to the Azure Blob Storage container ${containerName}.`, + ); + }) + .catch(reason => { + logger.error( + `Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` + + 'Make sure that the Azure project and container exist and the access key is setup correctly ' + + 'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + throw new Error( + `from Azure Blob Storage client library: ${reason.message}`, + ); + }); + + return new AzureBlobStoragePublish(storageClient, containerName, logger); + } + + constructor( + private readonly storageClient: BlobServiceClient, + private readonly containerName: string, + private readonly logger: Logger, + ) { + this.storageClient = storageClient; + this.containerName = containerName; + this.logger = logger; + } + + /** + * Upload all the files from the generated `directory` to the Azure Blob Storage container. + * Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html + */ + async publish({ entity, directory }: PublishRequest): Promise { + try { + // Note: Azure Blob Storage manages creation of parent directories if they do not exist. + // So collecting path of only the files is good enough. + const allFilesToUpload = await getFileTreeRecursively(directory); + + const uploadPromises: Array> = []; + + // Bound the number of concurrent batches. We want a bit of concurrency for + // performance reasons, but not so much that we starve the connection pool + // or start thrashing. + const limiter = limiterFactory(BATCH_CONCURRENCY); + + const promises = allFilesToUpload.map(filePath => { + // Remove the absolute path prefix of the source directory + // Path of all files to upload, relative to the root of the source directory + // e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png'] + const relativeFilePath = filePath.replace(`${directory}/`, ''); + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const destination = platformPath.normalize( + `${entityRootDir}/${relativeFilePath}`, + ); // Azure Blob Storage Container file relative path + + return limiter(async () => { + await uploadPromises.push( + this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(destination) + .uploadFile(filePath), + ); + }); + }); + + await Promise.all(promises).then(() => { + this.logger.info( + `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, + ); + }); + return; + } catch (e) { + const errorMessage = `Unable to upload file(s) to Azure Blob Storage. Error ${e.message}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + } + + private download(containerName: string, path: string): Promise { + return new Promise((resolve, reject) => { + const fileStreamChunks: Array = []; + this.storageClient + .getContainerClient(containerName) + .getBlockBlobClient(path) + .download() + .then(res => { + const body = res.readableStreamBody; + if (!body) { + reject(new Error(`Unable to parse the response data`)); + return; + } + body + .on('error', e => { + this.logger.error(e.message); + reject(e.message); + }) + .on('data', chunk => { + fileStreamChunks.push(chunk); + }) + .on('end', () => { + resolve(Buffer.concat(fileStreamChunks)); + }); + }); + }); + } + + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + try { + return await new Promise(resolve => { + const download = this.download( + this.containerName, + `${entityRootDir}/techdocs_metadata.json`, + ); + resolve(JSON5.parse(download.toString())); + }); + } catch (e) { + this.logger.error(e.message); + throw e; + } + } + + /** + * Express route middleware to serve static files on a route in techdocs-backend. + */ + docsRouter(): express.Handler { + return (req, res) => { + // Trim the leading forward slash + // filePath example - /default/Component/documented-component/index.html + const filePath = req.path.replace(/^\//, ''); + // Files with different extensions (CSS, HTML) need to be served with different headers + const fileExtension = platformPath.extname(filePath); + const responseHeaders = getHeadersForFileExtension(fileExtension); + + try { + this.download(this.containerName, filePath).then(fileContent => { + // Inject response headers + for (const [headerKey, headerValue] of Object.entries( + responseHeaders, + )) { + res.setHeader(headerKey, headerValue); + } + res.send(fileContent); + }); + } catch (e) { + this.logger.error(e.message); + res.status(404).send(e.message); + } + }; + } + + /** + * A helper function which checks if index.html of an Entity's docs site is available. This + * can be used to verify if there are any pre-generated docs available to serve. + */ + async hasDocsBeenGenerated(entity: Entity): Promise { + return new Promise(resolve => { + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(`${entityRootDir}/index.html`) + .exists() + .then((response: boolean) => { + resolve(response); + }) + .catch(() => { + resolve(false); + }); + }); + } +} diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index 43375b72d0..f8fb00647c 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -37,7 +37,7 @@ jest.spyOn(logger, 'info').mockReturnValue(logger); let publisher: PublisherBase; -beforeEach(() => { +beforeEach(async () => { const mockConfig = new ConfigReader({ techdocs: { requestUrl: 'http://localhost:7000', @@ -45,14 +45,13 @@ beforeEach(() => { type: 'googleGcs', googleGcs: { credentials: '{}', - projectId: 'gcp-project-id', bucketName: 'bucketName', }, }, }, }); - publisher = GoogleGCSPublish.fromConfig(mockConfig, logger); + publisher = await GoogleGCSPublish.fromConfig(mockConfig, logger); }); describe('GoogleGCSPublish', () => { diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index b33e2bfbe0..8876007504 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -24,60 +24,61 @@ import { Logger } from 'winston'; import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; -import { PublisherBase, PublishRequest } from './types'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import JSON5 from 'json5'; +import createLimiter from 'p-limit'; export class GoogleGCSPublish implements PublisherBase { - static fromConfig(config: Config, logger: Logger): PublisherBase { - let credentials = ''; - let projectId = ''; + static async fromConfig( + config: Config, + logger: Logger, + ): Promise { let bucketName = ''; try { - credentials = config.getString( - 'techdocs.publisher.googleGcs.credentials', - ); - projectId = config.getString('techdocs.publisher.googleGcs.projectId'); bucketName = config.getString('techdocs.publisher.googleGcs.bucketName'); } catch (error) { throw new Error( "Since techdocs.publisher.type is set to 'googleGcs' in your app config, " + - 'credentials, projectId and bucketName are required in techdocs.publisher.googleGcs ' + - 'required to authenticate with Google Cloud Storage.', + 'techdocs.publisher.googleGcs.bucketName is required.', ); } + // Credentials is an optional config. If missing, default GCS environment variables will be used. + // Read more here https://cloud.google.com/docs/authentication/production + const credentials = config.getOptionalString( + 'techdocs.publisher.googleGcs.credentials', + ); let credentialsJson = {}; - try { - credentialsJson = JSON.parse(credentials); - } catch (err) { - throw new Error( - 'Error in parsing techdocs.publisher.googleGcs.credentials config to JSON.', - ); + if (credentials) { + try { + credentialsJson = JSON.parse(credentials); + } catch (err) { + throw new Error( + 'Error in parsing techdocs.publisher.googleGcs.credentials config to JSON.', + ); + } } const storageClient = new Storage({ - credentials: credentialsJson, - projectId: projectId, + ...(credentials && { + credentials: credentialsJson, + }), }); // Check if the defined bucket exists. Being able to connect means the configuration is good // and the storage client will work. - storageClient - .bucket(bucketName) - .getMetadata() - .then(() => { - logger.info( - `Successfully connected to the GCS bucket ${bucketName} in the GCP project ${projectId}.`, - ); - }) - .catch(reason => { - logger.error( - `Could not retrieve metadata about the GCS bucket ${bucketName} in the GCP project ${projectId}. ` + - 'Make sure the GCP project and the bucket exists and the access key located at the path ' + - "techdocs.publisher.googleGcs.credentials defined in app config has the role 'Storage Object Creator'. " + - 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', - ); - throw new Error(`from GCS client library: ${reason.message}`); - }); + try { + await storageClient.bucket(bucketName).getMetadata(); + logger.info(`Successfully connected to the GCS bucket ${bucketName}.`); + } catch (err) { + logger.error( + `Could not retrieve metadata about the GCS bucket ${bucketName}. ` + + 'Make sure the bucket exists. Also make sure that authentication is setup either by explicitly defining ' + + 'techdocs.publisher.googleGcs.credentials in app config or by using environment variables. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + throw new Error(err.message); + } return new GoogleGCSPublish(storageClient, bucketName, logger); } @@ -102,6 +103,7 @@ export class GoogleGCSPublish implements PublisherBase { // So collecting path of only the files is good enough. const allFilesToUpload = await getFileTreeRecursively(directory); + const limiter = createLimiter(10); const uploadPromises: Array> = []; allFilesToUpload.forEach(filePath => { // Remove the absolute path prefix of the source directory @@ -110,12 +112,14 @@ export class GoogleGCSPublish implements PublisherBase { const relativeFilePath = filePath.replace(`${directory}/`, ''); const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; const destination = `${entityRootDir}/${relativeFilePath}`; // GCS Bucket file relative path - // TODO: Upload in chunks of ~10 files instead of all files at once. - uploadPromises.push( - this.storageClient.bucket(this.bucketName).upload(filePath, { - destination, - }), + + // Rate limit the concurrent execution of file uploads to batches of 10 (per publish) + const uploadFile = limiter(() => + this.storageClient + .bucket(this.bucketName) + .upload(filePath, { destination }), ); + uploadPromises.push(uploadFile); }); Promise.all(uploadPromises) @@ -133,7 +137,7 @@ export class GoogleGCSPublish implements PublisherBase { }); } - fetchTechDocsMetadata(entityName: EntityName): Promise { + fetchTechDocsMetadata(entityName: EntityName): Promise { return new Promise((resolve, reject) => { const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; @@ -153,7 +157,7 @@ export class GoogleGCSPublish implements PublisherBase { const techdocsMetadataJson = Buffer.concat( fileStreamChunks, ).toString(); - resolve(techdocsMetadataJson); + resolve(JSON5.parse(techdocsMetadataJson)); }); }); } @@ -171,29 +175,24 @@ export class GoogleGCSPublish implements PublisherBase { const fileExtension = path.extname(filePath); const responseHeaders = getHeadersForFileExtension(fileExtension); - const fileStreamChunks: Array = []; + // Pipe file chunks directly from storage to client. this.storageClient .bucket(this.bucketName) .file(filePath) .createReadStream() + .on('pipe', () => { + res.writeHead(200, responseHeaders); + }) .on('error', err => { this.logger.warn(err.message); - res.status(404).send(err.message); - }) - .on('data', chunk => { - fileStreamChunks.push(chunk); - }) - .on('end', () => { - const fileContent = Buffer.concat(fileStreamChunks).toString(); - // Inject response headers - for (const [headerKey, headerValue] of Object.entries( - responseHeaders, - )) { - res.setHeader(headerKey, headerValue); + // Send a 404 with a meaningful message if possible. + if (!res.headersSent) { + res.status(404).send(err.message); + } else { + res.destroy(); } - - res.send(fileContent); - }); + }) + .pipe(res); }; } diff --git a/packages/techdocs-common/src/stages/publish/index.ts b/packages/techdocs-common/src/stages/publish/index.ts index 494efe5ac1..dbf4ff732a 100644 --- a/packages/techdocs-common/src/stages/publish/index.ts +++ b/packages/techdocs-common/src/stages/publish/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ export { Publisher } from './publish'; -export type { PublisherBase, PublisherType } from './types'; +export type { PublisherBase, PublisherType, TechDocsMetadata } from './types'; diff --git a/packages/techdocs-common/src/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts index efd56285ef..cd1a17ab5f 100644 --- a/packages/techdocs-common/src/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -13,34 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -/* eslint-disable no-restricted-syntax */ -import fs from 'fs-extra'; -import path from 'path'; import { getVoidLogger, PluginEndpointDiscovery, } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; +import mockFs from 'mock-fs'; +import * as os from 'os'; import { LocalPublish } from './local'; -jest.mock('fs-extra', () => { - const fsOriginal = jest.requireActual('fs-extra'); - return { - ...fsOriginal, - access: jest.fn().mockImplementation((path, checkType, callback) => { - if ( - path.includes('http://localhost:7000/static') && - checkType === fs.constants.F_OK - ) { - callback(); - } else { - callback(new Error()); - } - }), - }; -}); - const createMockEntity = (annotations = {}) => { return { apiVersion: 'version', @@ -56,43 +37,33 @@ const createMockEntity = (annotations = {}) => { const logger = getVoidLogger(); +const tmpDir = + os.platform() === 'win32' ? 'C:\\tmp\\generatedDir' : '/tmp/generatedDir'; + describe('local publisher', () => { it('should publish generated documentation dir', async () => { - const testDiscovery: jest.Mocked = { - getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000'), - getExternalBaseUrl: jest.fn(), - }; - - const mockConfig = new ConfigReader({ - techdocs: { - requestUrl: 'http://localhost:7000', - storageUrl: 'http://localhost:7000/static/docs', + mockFs({ + [tmpDir]: { + 'index.html': '', }, }); + const testDiscovery: jest.Mocked = { + getBaseUrl: jest + .fn() + .mockResolvedValue('http://localhost:7000/api/techdocs'), + getExternalBaseUrl: jest.fn(), + }; + + const mockConfig = new ConfigReader({}); + const publisher = new LocalPublish(mockConfig, logger, testDiscovery); const mockEntity = createMockEntity(); - const tempDir = fs.mkdtempSync(`${__dirname}/test-component-folder-`); - expect(tempDir).toBeTruthy(); - fs.closeSync(fs.openSync(path.join(tempDir, '/mock-file'), 'w')); - await publisher.publish({ entity: mockEntity, directory: tempDir }); - - const publishDir = path.resolve( - __dirname, - `../../../../../plugins/techdocs-backend/static/docs/${mockEntity.metadata.name}`, - ); - const resultDir = path.resolve( - __dirname, - `../../../../../plugins/techdocs-backend/static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`, - ); - - expect(fs.existsSync(resultDir)).toBeTruthy(); - expect(fs.existsSync(path.join(resultDir, '/mock-file'))).toBeTruthy(); + await publisher.publish({ entity: mockEntity, directory: tmpDir }); expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true); - fs.removeSync(publishDir); - fs.removeSync(tempDir); + mockFs.restore(); }); }); diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index 2739940530..e349a2119a 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -13,36 +13,52 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import fetch from 'cross-fetch'; +import { + PluginEndpointDiscovery, + resolvePackagePath, +} from '@backstage/backend-common'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; import express from 'express'; import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; import { Logger } from 'winston'; -import { Entity, EntityName } from '@backstage/catalog-model'; import { - resolvePackagePath, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; -import { Config } from '@backstage/config'; -import { PublisherBase, PublishRequest, PublishResponse } from './types'; + PublisherBase, + PublishRequest, + PublishResponse, + TechDocsMetadata, +} from './types'; -const staticDocsDir = resolvePackagePath( - '@backstage/plugin-techdocs-backend', - 'static/docs', -); +// TODO: Use a more persistent storage than node_modules or /tmp directory. +// Make it configurable with techdocs.publisher.local.publishDirectory +let staticDocsDir = ''; +try { + staticDocsDir = resolvePackagePath( + '@backstage/plugin-techdocs-backend', + 'static/docs', + ); +} catch (err) { + // This will most probably never be used. + // The try/catch is introduced so that techdocs-cli can import @backstage/techdocs-common + // on CI/CD without installing techdocs backend plugin. + staticDocsDir = os.tmpdir(); +} /** * Local publisher which uses the local filesystem to store the generated static files. It uses a directory * called "static" at the root of techdocs-backend plugin. */ export class LocalPublish implements PublisherBase { - private readonly config: Config; - private readonly logger: Logger; - private readonly discovery: PluginEndpointDiscovery; - + // TODO: Use a static fromConfig method to create a LocalPublish instance, similar to aws/gcs publishers. + // Move the logic of setting staticDocsDir based on config over to fromConfig, + // and set the value as a class parameter. constructor( - config: Config, - logger: Logger, - discovery: PluginEndpointDiscovery, + // @ts-ignore + private readonly config: Config, + private readonly logger: Logger, + private readonly discovery: PluginEndpointDiscovery, ) { this.config = config; this.logger = logger; @@ -52,9 +68,8 @@ export class LocalPublish implements PublisherBase { publish({ entity, directory }: PublishRequest): Promise { const entityNamespace = entity.metadata.namespace ?? 'default'; - const publishDir = resolvePackagePath( - '@backstage/plugin-techdocs-backend', - 'static/docs', + const publishDir = path.join( + staticDocsDir, entityNamespace, entity.kind, entity.metadata.name, @@ -88,34 +103,25 @@ export class LocalPublish implements PublisherBase { }); } - fetchTechDocsMetadata(entityName: EntityName): Promise { - return new Promise((resolve, reject) => { - this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { - const storageUrl = new URL( - new URL(this.config.getString('techdocs.storageUrl')).pathname, - techdocsApiUrl, - ).toString(); + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { + const metadataPath = path.join( + staticDocsDir, + entityName.namespace, + entityName.kind, + entityName.name, + 'techdocs_metadata.json', + ); - const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; - const metadataURL = `${storageUrl}/${entityRootDir}/techdocs_metadata.json`; - fetch(metadataURL) - .then(response => - response - .json() - .then(techdocsMetadataJson => resolve(techdocsMetadataJson)) - .catch(err => { - reject( - `Unable to parse metadata JSON for ${entityRootDir}. Error: ${err}`, - ); - }), - ) - .catch(err => { - reject( - `Unable to fetch metadata for ${entityRootDir}. Error ${err}`, - ); - }); - }); - }); + try { + return await fs.readJson(metadataPath); + } catch (err) { + this.logger.error( + `Unable to read techdocs_metadata.json at ${metadataPath}. Error: ${err}`, + ); + throw new Error(err.message); + } } docsRouter(): express.Handler { @@ -124,24 +130,21 @@ export class LocalPublish implements PublisherBase { async hasDocsBeenGenerated(entity: Entity): Promise { const namespace = entity.metadata.namespace ?? 'default'; - return new Promise(resolve => { - this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { - const storageUrl = new URL( - new URL(this.config.getString('techdocs.storageUrl')).pathname, - techdocsApiUrl, - ).toString(); - const entityRootDir = `${namespace}/${entity.kind}/${entity.metadata.name}`; - const indexHtmlUrl = `${storageUrl}/${entityRootDir}/index.html`; - // Check if the file exists - fs.access(indexHtmlUrl, fs.constants.F_OK, err => { - if (err) { - resolve(false); - } else { - resolve(true); - } - }); - }); - }); + const indexHtmlPath = path.join( + staticDocsDir, + namespace, + entity.kind, + entity.metadata.name, + 'index.html', + ); + + // Check if the file exists + try { + fs.access(indexHtmlPath, fs.constants.F_OK); + return true; + } catch (err) { + return false; + } } } diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index d7cc257b89..d1faf82b78 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -22,6 +22,7 @@ import { Publisher } from './publish'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; const logger = getVoidLogger(); const discovery: jest.Mocked = { @@ -30,6 +31,10 @@ const discovery: jest.Mocked = { }; describe('Publisher', () => { + beforeEach(() => { + jest.resetModules(); // clear the cache + }); + it('should create local publisher by default', async () => { const mockConfig = new ConfigReader({ techdocs: { @@ -69,7 +74,6 @@ describe('Publisher', () => { type: 'googleGcs', googleGcs: { credentials: '{}', - projectId: 'gcp-project-id', bucketName: 'bucketName', }, }, @@ -106,4 +110,55 @@ describe('Publisher', () => { }); expect(publisher).toBeInstanceOf(AwsS3Publish); }); + + it('should create Azure Blob Storage publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(AzureBlobStoragePublish); + }); + + it('should create Azure Blob Storage publisher from environment variables', async () => { + process.env.AZURE_TENANT_ID = 'AZURE_TENANT_ID'; + process.env.AZURE_CLIENT_ID = 'AZURE_CLIENT_ID'; + process.env.AZURE_CLIENT_SECRET = 'AZURE_CLIENT_SECRET'; + + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(AzureBlobStoragePublish); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index caaf8a4cec..a4e33a2d9c 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -21,6 +21,7 @@ import { PublisherType, PublisherBase } from './types'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; type factoryOptions = { logger: Logger; @@ -43,10 +44,15 @@ export class Publisher { switch (publisherType) { case 'googleGcs': logger.info('Creating Google Storage Bucket publisher for TechDocs'); - return GoogleGCSPublish.fromConfig(config, logger); + return await GoogleGCSPublish.fromConfig(config, logger); case 'awsS3': logger.info('Creating AWS S3 Bucket publisher for TechDocs'); return AwsS3Publish.fromConfig(config, logger); + case 'azureBlobStorage': + logger.info( + 'Creating Azure Blob Storage Container publisher for TechDocs', + ); + return AzureBlobStoragePublish.fromConfig(config, logger); case 'local': logger.info('Creating Local publisher for TechDocs'); return new LocalPublish(config, logger, discovery); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index 24a9d05adf..5e953deb81 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -19,7 +19,11 @@ import express from 'express'; /** * Key for all the different types of TechDocs publishers that are supported. */ -export type PublisherType = 'local' | 'googleGcs' | 'awsS3'; +export type PublisherType = + | 'local' + | 'googleGcs' + | 'awsS3' + | 'azureBlobStorage'; export type PublishRequest = { entity: Entity; @@ -32,6 +36,14 @@ export type PublishResponse = { remoteUrl?: string; } | void; +/** + * Type to hold metadata found in techdocs_metadata.json and associated with each site + */ +export type TechDocsMetadata = { + site_name: string; + site_description: string; +}; + /** * Base class for a TechDocs publisher (e.g. Local, Google GCS Bucket, AWS S3, etc.) * The publisher handles publishing of the generated static files after the prepare and generate steps of TechDocs. @@ -50,7 +62,7 @@ export interface PublisherBase { * Retrieve TechDocs Metadata about a site e.g. name, contributors, last updated, etc. * This API uses the techdocs_metadata.json file that co-exists along with the generated docs. */ - fetchTechDocsMetadata(entityName: EntityName): Promise; + fetchTechDocsMetadata(entityName: EntityName): Promise; /** * Route middleware to serve static documentation files for an entity. diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 8c5a7c1f39..5cea83fa03 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -31,7 +31,7 @@ "dependencies": { "@backstage/core-api": "^0.2.7", "@backstage/test-utils-core": "^0.1.1", - "@backstage/theme": "^0.2.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.3", + "@backstage/cli": "^0.6.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx index 29f8d97793..4899f07061 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -21,9 +21,11 @@ import { Route, Routes } from 'react-router'; import { withLogCollector } from '@backstage/test-utils-core'; import { useApi, + useRouteRef, errorApiRef, ApiProvider, ApiRegistry, + createRouteRef, } from '@backstage/core-api'; import { MockErrorApi } from './apis'; @@ -113,4 +115,29 @@ describe('wrapInTestApp', () => { expect(rendered.getByText('foo')).toBeInTheDocument(); expect(mockErrorApi.getErrors()).toEqual([{ error: new Error('NOPE') }]); }); + + it('should allow route refs to be mounted on specific paths', async () => { + const aRouteRef = createRouteRef({ title: 'A' }); + const bRouteRef = createRouteRef({ title: 'B', params: ['name'] }); + + const MyComponent = () => { + const a = useRouteRef(aRouteRef); + const b = useRouteRef(bRouteRef); + return ( +
+
Link A: {a()}
+
Link B: {b({ name: 'x' })}
+
+ ); + }; + + const rendered = await renderInTestApp(, { + mountedRoutes: { + '/my-a-path': aRouteRef, + '/my-b-path/:name': bRouteRef, + }, + }); + expect(rendered.getByText('Link A: /my-a-path')).toBeInTheDocument(); + expect(rendered.getByText('Link B: /my-b-path/x')).toBeInTheDocument(); + }); }); diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 1f6c7622b8..cc67a5b707 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -21,6 +21,9 @@ import { lightTheme } from '@backstage/theme'; import privateExports, { defaultSystemIcons, BootErrorPageProps, + RouteRef, + createPlugin, + createRoutableExtension, } from '@backstage/core-api'; import { RenderResult } from '@testing-library/react'; import { renderWithEffects } from '@backstage/test-utils-core'; @@ -44,6 +47,22 @@ type TestAppOptions = { * Initial route entries to pass along as `initialEntries` to the router. */ routeEntries?: string[]; + + /** + * An object of paths to mount route ref on, with the key being the path and the value + * being the RouteRef that the path will be bound to. This allows the route refs to be + * used by `useRouteRef` in the rendered elements. + * + * @example + * wrapInTestApp(, { + * mountedRoutes: { + * '/my-path': myRouteRef, + * } + * }) + * // ... + * const link = useRouteRef(myRouteRef) + */ + mountedRoutes?: { [path: string]: RouteRef }; }; /** @@ -90,12 +109,27 @@ export function wrapInTestApp( Wrapper = () => Component as React.ReactElement; } + const routePlugin = createPlugin({ id: 'mock-route-plugin' }); + const routeElements = Object.entries(options.mountedRoutes ?? {}).map( + ([path, routeRef]) => { + const PageComponent = () =>
Mounted at {path}
; + const Page = routePlugin.provide( + createRoutableExtension({ + component: async () => PageComponent, + mountPoint: routeRef, + }), + ); + return } />; + }, + ); + const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); return ( + {routeElements} {/* The path of * here is needed to be set as a catch all, so it will render the wrapper element * and work with nested routes if they exist too */} } /> diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 13d95ab856..4ee58b2201 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/theme +## 0.2.3 + +### Patch Changes + +- c810082ae: Updates warning text color to align to updated `WarningPanel` styling + ## 0.2.2 ### Patch Changes diff --git a/packages/theme/package.json b/packages/theme/package.json index 54f43a1443..31abd9acff 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.2.2", + "version": "0.2.3", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.4.1" + "@backstage/cli": "^0.6.0" }, "files": [ "dist" diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 5b233ac303..fd13343ca8 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -58,7 +58,7 @@ export const lightTheme = createTheme({ infoBackground: '#ebf5ff', errorText: '#CA001B', infoText: '#004e8a', - warningText: '#FEFEFE', + warningText: '#000000', linkHover: '#2196F3', link: '#0A6EBE', gold: yellow.A700, @@ -120,7 +120,7 @@ export const darkTheme = createTheme({ infoBackground: '#ebf5ff', errorText: '#CA001B', infoText: '#004e8a', - warningText: '#FEFEFE', + warningText: '#000000', linkHover: '#2196F3', link: '#0A6EBE', gold: yellow.A700, diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index 995dfdc175..4735beb150 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,52 @@ # @backstage/plugin-api-docs +## 0.4.4 + +### Patch Changes + +- 7fc89bae2: Display owner and system as entity page links in the tables of the `api-docs` + plugin. + + Move `isOwnerOf` and `getEntityRelations` from `@backstage/plugin-catalog` to + `@backstage/plugin-catalog-react` and export it from there to use it by other + plugins. + +- bc5082a00: Migrate to new composability API, exporting the plugin as `apiDocsPlugin`, index page as `ApiExplorerPage`, and entity page cards as `EntityApiDefinitionCard`, `EntityConsumedApisCard`, `EntityConsumingComponentsCard`, `EntityProvidedApisCard`, and `EntityProvidingComponentsCard`. +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.4.3 + +### Patch Changes + +- 8855f61f6: Update `@asyncapi/react-component` to 0.18.2 +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + ## 0.4.2 ### Patch Changes diff --git a/plugins/api-docs/dev/example-api.yaml b/plugins/api-docs/dev/example-api.yaml new file mode 100644 index 0000000000..a314b4e255 --- /dev/null +++ b/plugins/api-docs/dev/example-api.yaml @@ -0,0 +1,124 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: petstore + description: The petstore API + tags: + - store + - rest +spec: + type: openapi + lifecycle: experimental + owner: team-c + definition: | + openapi: "3.0.0" + info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT + servers: + - url: http://petstore.swagger.io/v1 + paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/plugins/api-docs/dev/index.tsx b/plugins/api-docs/dev/index.tsx index 812a5585d4..4bf0215f32 100644 --- a/plugins/api-docs/dev/index.tsx +++ b/plugins/api-docs/dev/index.tsx @@ -14,7 +14,23 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { ApiExplorerPage, apiDocsPlugin } from '../src/plugin'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import petstoreApiEntity from './example-api.yaml'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + (({ + async getEntities() { + return { items: [petstoreApiEntity] }; + }, + } as unknown) as typeof catalogApiRef.T), + }) + .registerPlugin(apiDocsPlugin) + .addPage({ element: }) + .render(); diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 23dd66a9a9..175b831ada 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.4.2", + "version": "0.4.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.9", - "@backstage/theme": "^0.2.2", - "@kyma-project/asyncapi-react": "^0.14.2", + "@asyncapi/react-component": "^0.18.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -49,8 +49,8 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx index d59699ab9a..0107472acf 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx @@ -16,6 +16,7 @@ import { ApiEntity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -54,7 +55,7 @@ paths: get: summary: List all artists responses: - "200": + "200": description: Success `; const apiEntity: ApiEntity = { @@ -74,14 +75,16 @@ paths: type: 'openapi', title: 'OpenAPI', rawLanguage: 'yaml', - component: definition => ( - + component: definitionString => ( + ), }); const { getByText } = await renderInTestApp( - + + + , ); @@ -110,7 +113,9 @@ paths: const { getByText } = await renderInTestApp( - + + + , ); diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx index 19370bfc18..0a4386e275 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx @@ -15,6 +15,7 @@ */ import { ApiEntity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { CardTab, TabbedCard, useApi } from '@backstage/core'; import { Alert } from '@material-ui/lab'; import React from 'react'; @@ -22,29 +23,31 @@ import { apiDocsConfigRef } from '../../config'; import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget'; type Props = { + /** @deprecated The entity is now grabbed from context instead */ apiEntity?: ApiEntity; }; -export const ApiDefinitionCard = ({ apiEntity }: Props) => { +export const ApiDefinitionCard = (_: Props) => { + const entity = useEntity().entity as ApiEntity; const config = useApi(apiDocsConfigRef); const { getApiDefinitionWidget } = config; - if (!apiEntity) { + if (!entity) { return Could not fetch the API; } - const definitionWidget = getApiDefinitionWidget(apiEntity); + const definitionWidget = getApiDefinitionWidget(entity); if (definitionWidget) { return ( - + - {definitionWidget.component(apiEntity.spec.definition)} + {definitionWidget.component(entity.spec.definition)} @@ -53,13 +56,13 @@ export const ApiDefinitionCard = ({ apiEntity }: Props) => { return ( + , ]} diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index 10495bb957..67462b630c 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -16,7 +16,7 @@ import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index a602877525..df404ce7cd 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -15,7 +15,7 @@ */ import { Content, ContentHeader, SupportButton, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Button } from '@material-ui/core'; import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx index 494695e277..bcb310be46 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.test.tsx @@ -61,7 +61,7 @@ describe('ApiCatalogTable component', () => { ), ); const errorMessage = await rendered.findByText( - /Error encountered while fetching catalog entities./, + /Could not fetch catalog entities./, ); expect(errorMessage).toBeInTheDocument(); }); diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 149f7470c3..701ca457df 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -14,55 +14,92 @@ * limitations under the License. */ -import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model'; import { + ApiEntityV1alpha1, + Entity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; +import { + CodeSnippet, Table, TableColumn, TableFilter, TableState, useQueryParamState, + WarningPanel, } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import { Chip } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; import React from 'react'; import { ApiTypeTitle } from '../ApiDefinitionCard'; -import { EntityLink } from '../EntityLink'; -const columns: TableColumn[] = [ +type EntityRow = { + entity: ApiEntityV1alpha1; + resolved: { + name: string; + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'resolved.name', highlight: true, - render: (entity: any) => ( - {entity.metadata.name} + render: ({ entity }) => ( + + ), + }, + { + title: 'System', + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( + ), }, { title: 'Owner', - field: 'spec.owner', - }, - { - title: 'Lifecycle', - field: 'spec.lifecycle', - }, - { - title: 'Type', - field: 'spec.type', - render: (entity: Entity) => ( - + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + ), }, + { + title: 'Lifecycle', + field: 'entity.spec.lifecycle', + }, + { + title: 'Type', + field: 'entity.spec.type', + render: ({ entity }) => , + }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', }, { title: 'Tags', - field: 'metadata.tags', + field: 'entity.metadata.tags', cellStyle: { padding: '0px 16px 0px 20px', }, - render: (entity: Entity) => ( + render: ({ entity }) => ( <> {entity.metadata.tags && entity.metadata.tags.map(t => ( @@ -115,16 +152,42 @@ export const ApiExplorerTable = ({ if (error) { return ( -
- - Error encountered while fetching catalog entities. {error.toString()} - -
+ + + ); } + const rows = entities.map(entity => { + const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'system', + }); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity: entity as ApiEntityV1alpha1, + resolved: { + name: formatEntityRefTitle(entity, { + defaultKind: 'API', + }), + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), + partOfSystemRelations, + }, + }; + }); + return ( - isLoading={loading} columns={columns} options={{ @@ -134,7 +197,7 @@ export const ApiExplorerTable = ({ padding: 'dense', showEmptyDataSourceMessage: !loading, }} - data={entities} + data={rows} filters={filters} initialState={queryParamState} onStateChange={setQueryParamState} diff --git a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx index 7db62433eb..30716a61fb 100644 --- a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx +++ b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx @@ -14,37 +14,73 @@ * limitations under the License. */ -import { ApiEntity } from '@backstage/catalog-model'; +import { + ApiEntity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { Table, TableColumn } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import React from 'react'; import { ApiTypeTitle } from '../ApiDefinitionCard'; -import { EntityLink } from '../EntityLink'; -const columns: TableColumn[] = [ +type EntityRow = { + entity: ApiEntity; + resolved: { + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'entity.metadata.name', highlight: true, - render: (entity: any) => ( - {entity.metadata.name} + render: ({ entity }) => ( + {entity.metadata.name} + ), + }, + { + title: 'System', + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( + ), }, { title: 'Owner', - field: 'spec.owner', + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + + ), }, { title: 'Lifecycle', - field: 'spec.lifecycle', + field: 'entity.spec.lifecycle', }, { title: 'Type', - field: 'spec.type', - render: (entity: ApiEntity) => , + field: 'entity.spec.type', + render: ({ entity }) => , }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', width: 'auto', }, ]; @@ -65,8 +101,40 @@ export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { tableStyle.height = 'calc(100% - 10px)'; } + const rows = entities + // TODO: For now we skip all APIs that we can't find without a warning! + .filter(e => e !== undefined) + .map(entity => { + const partOfSystemRelations = getEntityRelations( + entity, + RELATION_PART_OF, + { + kind: 'system', + }, + ); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity: entity as ApiEntity, + resolved: { + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), + partOfSystemRelations, + }, + }; + }); + return ( - + columns={columns} title={title} style={tableStyle} @@ -77,8 +145,7 @@ export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { actionsColumnIndex: -1, padding: 'dense', }} - // TODO: For now we skip all APIs that we can't find without a warning! - data={entities.filter(e => e !== undefined) as ApiEntity[]} + data={rows} /> ); }; diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index a4a1e15511..051b9588a0 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -14,9 +14,18 @@ * limitations under the License. */ -import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; +import { + Entity, + RELATION_CONSUMES_API, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -63,7 +72,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -99,10 +110,27 @@ describe('', () => { }, spec: { type: 'openapi', - owner: 'Test', lifecycle: 'production', definition: '...', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ type: 'openapi', @@ -112,7 +140,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -121,6 +151,7 @@ describe('', () => { expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/OpenAPI/)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/MySystem/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx index 0bd4919554..8a808a6488 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_CONSUMES_API, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { ApisTable } from './ApisTable'; @@ -37,11 +38,13 @@ const ApisCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ConsumedApisCard = ({ entity, variant = 'gridItem' }: Props) => { +export const ConsumedApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_CONSUMES_API, diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index 1f42ff9060..482534f58a 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -14,9 +14,18 @@ * limitations under the License. */ -import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; +import { + Entity, + RELATION_OWNED_BY, + RELATION_PART_OF, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -63,7 +72,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -99,10 +110,27 @@ describe('', () => { }, spec: { type: 'openapi', - owner: 'Test', lifecycle: 'production', definition: '...', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ type: 'openapi', @@ -112,7 +140,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -120,6 +150,7 @@ describe('', () => { expect(getByText(/Provided APIs/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/OpenAPI/)).toBeInTheDocument(); + expect(getByText(/MySystem/)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx index 618f2dc1f6..ed6e893a03 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { ApisTable } from './ApisTable'; @@ -37,11 +38,13 @@ const ApisCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ProvidedApisCard = ({ entity, variant = 'gridItem' }: Props) => { +export const ProvidedApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_PROVIDES_API, diff --git a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx index 86f524d099..a908a91b14 100644 --- a/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx +++ b/plugins/api-docs/src/components/AsyncApiDefinitionWidget/AsyncApiDefinitionWidget.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import AsyncApi from '@kyma-project/asyncapi-react'; +import AsyncApi from '@asyncapi/react-component'; +import '@asyncapi/react-component/lib/styles/fiori.css'; +import { fade, makeStyles } from '@material-ui/core/styles'; import React from 'react'; -import { makeStyles, fade } from '@material-ui/core/styles'; -import '@kyma-project/asyncapi-react/lib/styles/fiori.css'; const useStyles = makeStyles(theme => ({ root: { diff --git a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx index 1b62a56d10..bd9b0c765f 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx @@ -14,35 +14,71 @@ * limitations under the License. */ -import { ComponentEntity } from '@backstage/catalog-model'; +import { + ComponentEntity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { Table, TableColumn } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import React from 'react'; -import { EntityLink } from '../EntityLink'; -const columns: TableColumn[] = [ +type EntityRow = { + entity: ComponentEntity; + resolved: { + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'entity.metadata.name', highlight: true, - render: (entity: any) => ( - {entity.metadata.name} + render: ({ entity }) => ( + {entity.metadata.name} + ), + }, + { + title: 'System', + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( + ), }, { title: 'Owner', - field: 'spec.owner', + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + + ), }, { title: 'Lifecycle', - field: 'spec.lifecycle', + field: 'entity.spec.lifecycle', }, { title: 'Type', - field: 'spec.type', + field: 'entity.spec.type', }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', width: 'auto', }, ]; @@ -68,8 +104,40 @@ export const ComponentsTable = ({ tableStyle.height = 'calc(100% - 10px)'; } + const rows = entities + // TODO: For now we skip all Components that we can't find without a warning! + .filter(e => e !== undefined) + .map(entity => { + const partOfSystemRelations = getEntityRelations( + entity, + RELATION_PART_OF, + { + kind: 'system', + }, + ); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity: entity as ComponentEntity, + resolved: { + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), + partOfSystemRelations, + }, + }; + }); + return ( - + columns={columns} title={title} style={tableStyle} @@ -80,8 +148,7 @@ export const ComponentsTable = ({ actionsColumnIndex: -1, padding: 'dense', }} - // TODO: For now we skip all APIs that we can't find without a warning! - data={entities.filter(e => e !== undefined) as ComponentEntity[]} + data={rows} /> ); }; diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 606ff7e77b..f45a48842d 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -14,9 +14,18 @@ * limitations under the License. */ -import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; +import { + Entity, + RELATION_API_CONSUMED_BY, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -62,7 +71,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -104,14 +115,33 @@ describe('', () => { }, spec: { type: 'service', - owner: 'Test', lifecycle: 'production', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); const { getByText } = await renderInTestApp( - + + + , ); @@ -119,6 +149,7 @@ describe('', () => { expect(getByText(/Consumers/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/MySystem/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx index 0431367aa2..56d2fb977d 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_API_CONSUMED_BY, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { MissingConsumesApisEmptyState } from '../EmptyState'; @@ -37,14 +38,13 @@ const ComponentsCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ConsumingComponentsCard = ({ - entity, - variant = 'gridItem', -}: Props) => { +export const ConsumingComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_API_CONSUMED_BY, diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index d1cec1722a..4c55393d13 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -14,9 +14,18 @@ * limitations under the License. */ -import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; +import { + Entity, + RELATION_API_PROVIDED_BY, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -62,7 +71,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -104,14 +115,33 @@ describe('', () => { }, spec: { type: 'service', - owner: 'Test', lifecycle: 'production', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); const { getByText } = await renderInTestApp( - + + + , ); @@ -119,6 +149,7 @@ describe('', () => { expect(getByText(/Providers/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/MySystem/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx index 9e405a3af3..959ff3feed 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_API_PROVIDED_BY, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { MissingProvidesApisEmptyState } from '../EmptyState'; @@ -37,14 +38,13 @@ const ComponentsCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ProvidingComponentsCard = ({ - entity, - variant = 'gridItem', -}: Props) => { +export const ProvidingComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_API_PROVIDED_BY, diff --git a/plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx b/plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx deleted file mode 100644 index 9221b96604..0000000000 --- a/plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Entity } from '@backstage/catalog-model'; -import { renderInTestApp } from '@backstage/test-utils'; -import React from 'react'; -import { EntityLink } from './EntityLink'; - -describe('', () => { - it('provides link to entity page', async () => { - const entity: Entity = { - apiVersion: 'v1', - kind: 'Component', - metadata: { - name: 'my-name', - namespace: 'my-namespace', - }, - }; - const { getByText } = await renderInTestApp( - inner, - ); - expect(getByText(/inner/i)).toBeInTheDocument(); - expect(getByText(/inner/i)).toHaveAttribute( - 'href', - '/catalog/my-namespace/component/my-name', - ); - }); -}); diff --git a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx b/plugins/api-docs/src/components/EntityLink/EntityLink.tsx deleted file mode 100644 index 98b2103882..0000000000 --- a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx +++ /dev/null @@ -1,40 +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 { Entity } from '@backstage/catalog-model'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; -import { Link } from '@material-ui/core'; -import React, { PropsWithChildren } from 'react'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; - -type Props = { - entity: Entity; -}; - -// TODO: Could be useful for others too, as part of the catalog plugin -export const EntityLink = ({ entity, children }: PropsWithChildren) => { - return ( - - {children} - - ); -}; diff --git a/plugins/api-docs/src/components/useRelatedEntities.ts b/plugins/api-docs/src/components/useRelatedEntities.ts index 847ec30578..5c01d38189 100644 --- a/plugins/api-docs/src/components/useRelatedEntities.ts +++ b/plugins/api-docs/src/components/useRelatedEntities.ts @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useAsyncRetry } from 'react-use'; // TODO: Maybe this hook is interesting for others too? diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index f09aeb1038..e7484f77c3 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -15,4 +15,13 @@ */ export * from './components'; -export { plugin } from './plugin'; +export { + apiDocsPlugin, + apiDocsPlugin as plugin, + ApiExplorerPage, + EntityApiDefinitionCard, + EntityConsumedApisCard, + EntityConsumingComponentsCard, + EntityProvidedApisCard, + EntityProvidingComponentsCard, +} from './plugin'; diff --git a/plugins/api-docs/src/plugin.test.ts b/plugins/api-docs/src/plugin.test.ts index 054e804382..eaafd36b3d 100644 --- a/plugins/api-docs/src/plugin.test.ts +++ b/plugins/api-docs/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { apiDocsPlugin } from './plugin'; describe('api-docs', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(apiDocsPlugin).toBeDefined(); }); }); diff --git a/plugins/api-docs/src/plugin.ts b/plugins/api-docs/src/plugin.ts index 06db03f2e1..429fd60095 100644 --- a/plugins/api-docs/src/plugin.ts +++ b/plugins/api-docs/src/plugin.ts @@ -15,14 +15,22 @@ */ import { ApiEntity } from '@backstage/catalog-model'; -import { createApiFactory, createPlugin } from '@backstage/core'; -import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage'; +import { + createApiFactory, + createPlugin, + createRoutableExtension, + createComponentExtension, +} from '@backstage/core'; +import { ApiExplorerPage as Page } from './components/ApiExplorerPage/ApiExplorerPage'; import { defaultDefinitionWidgets } from './components/ApiDefinitionCard'; import { rootRoute } from './routes'; import { apiDocsConfigRef } from './config'; -export const plugin = createPlugin({ +export const apiDocsPlugin = createPlugin({ id: 'api-docs', + routes: { + root: rootRoute, + }, apis: [ createApiFactory({ api: apiDocsConfigRef, @@ -38,6 +46,63 @@ export const plugin = createPlugin({ }), ], register({ router }) { - router.addRoute(rootRoute, ApiExplorerPage); + router.addRoute(rootRoute, Page); }, }); + +export const ApiExplorerPage = apiDocsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ApiExplorerPage').then(m => m.ApiExplorerPage), + mountPoint: rootRoute, + }), +); + +export const EntityApiDefinitionCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ApiDefinitionCard').then(m => m.ApiDefinitionCard), + }, + }), +); + +export const EntityConsumedApisCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ApisCards').then(m => m.ConsumedApisCard), + }, + }), +); + +export const EntityConsumingComponentsCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ComponentsCards').then( + m => m.ConsumingComponentsCard, + ), + }, + }), +); + +export const EntityProvidedApisCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ApisCards').then(m => m.ProvidedApisCard), + }, + }), +); + +export const EntityProvidingComponentsCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ComponentsCards').then( + m => m.ProvidingComponentsCard, + ), + }, + }), +); diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index e16ac1fb9c..b57e0d25fb 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,40 @@ # @backstage/plugin-app-backend +## 0.3.6 + +### Patch Changes + +- e9aab60c7: Failures to load the frontend configuration schema now throws an error that includes more context and instructions for how to fix the issue. +- Updated dependencies [2430ee7c2] +- Updated dependencies [062df71db] +- Updated dependencies [e9aab60c7] + - @backstage/backend-common@0.5.2 + - @backstage/config-loader@0.5.1 + +## 0.3.5 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] + - @backstage/backend-common@0.5.1 + - @backstage/config-loader@0.5.0 + +## 0.3.4 + +### Patch Changes + +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] + - @backstage/backend-common@0.5.0 + ## 0.3.3 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 0dde9c9d13..cca36a51f8 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.3.3", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.0", - "@backstage/config-loader": "^0.4.0", + "@backstage/backend-common": "^0.5.2", + "@backstage/config-loader": "^0.5.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -40,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.1", + "@backstage/cli": "^0.6.0", "@types/supertest": "^2.0.8", "msw": "^0.20.5", "supertest": "^4.0.2" diff --git a/plugins/app-backend/src/lib/config.ts b/plugins/app-backend/src/lib/config.ts index 99590c1e2d..0607956894 100644 --- a/plugins/app-backend/src/lib/config.ts +++ b/plugins/app-backend/src/lib/config.ts @@ -84,13 +84,21 @@ export async function readConfigs(options: ReadOptions): Promise { const schemaPath = resolvePath(appDistDir, '.config-schema.json'); if (await fs.pathExists(schemaPath)) { const serializedSchema = await fs.readJson(schemaPath); - const schema = await loadConfigSchema({ serialized: serializedSchema }); - const frontendConfigs = await schema.process( - [{ data: config.get() as JsonObject, context: 'app' }], - { visibility: ['frontend'] }, - ); - appConfigs.push(...frontendConfigs); + try { + const schema = await loadConfigSchema({ serialized: serializedSchema }); + + const frontendConfigs = await schema.process( + [{ data: config.get() as JsonObject, context: 'app' }], + { visibility: ['frontend'] }, + ); + appConfigs.push(...frontendConfigs); + } catch (error) { + throw new Error( + 'Invalid schema embedded in the app bundle, to fix this issue you need ' + + `to correct the schema and then rebuild the app bundle. ${error}`, + ); + } } return appConfigs; diff --git a/plugins/app-backend/src/service/router.ts b/plugins/app-backend/src/service/router.ts index 65cbddaae6..4379fcccb9 100644 --- a/plugins/app-backend/src/service/router.ts +++ b/plugins/app-backend/src/service/router.ts @@ -26,8 +26,34 @@ import fs from 'fs-extra'; export interface RouterOptions { config: Config; logger: Logger; + + /** + * The name of the app package that content should be served from. The same app package should be + * added as a dependency to the backend package in order for it to be accessible at runtime. + * + * In a typical setup with a single app package this would be set to 'app'. + */ appPackageName: string; + + /** + * A request handler to handle requests for static content that are not present in the app bundle. + * + * This can be used to avoid issues with clients on older deployment versions trying to access lazy + * loaded content that is no longer present. Typically the requests would fall back to a long-term + * object store where all recently deployed versions of the app are present. + */ staticFallbackHandler?: express.Handler; + + /** + * Disables the configuration injection. This can be useful if you're running in an environment + * with a read-only filesystem, or for some other reason don't want configuration to be injected. + * + * Note that this will cause the configuration used when building the app bundle to be used, unless + * a separate configuration loading strategy is set up. + * + * This also disables configuration injection though `APP_CONFIG_` environment variables. + */ + disableConfigInjection?: boolean; } export async function createRouter( @@ -48,13 +74,15 @@ export async function createRouter( logger.info(`Serving static app content from ${appDistDir}`); - const appConfigs = await readConfigs({ - config, - appDistDir, - env: process.env, - }); + if (!options.disableConfigInjection) { + const appConfigs = await readConfigs({ + config, + appDistDir, + env: process.env, + }); - await injectConfig({ appConfigs, logger, staticDir }); + await injectConfig({ appConfigs, logger, staticDir }); + } const router = Router(); diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 7d67ec0273..e3ff6aaa43 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,69 @@ # @backstage/plugin-auth-backend +## 0.2.12 + +### Patch Changes + +- d7b1d317f: Fixed serialization issue with caching of public keys in AWS ALB auth provider +- 39b05b9ae: Use .text instead of .json for ALB key response +- 4eaa06057: Fix AWS ALB issuer check +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + +## 0.2.11 + +### Patch Changes + +- 0643a3336: Add AWS ALB OIDC reverse proxy authentication provider +- a2291d7cc: Optional identity token authorization of api requests +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + - @backstage/catalog-client@0.3.5 + +## 0.2.10 + +### Patch Changes + +- 468579734: Allow blank certificates and support logout URLs in the SAML provider. +- Updated dependencies [f3b064e1c] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/backend-common@0.4.3 + +## 0.2.9 + +### Patch Changes + +- 0289a059c: Add support for the majority of the Core configurations for Passport-SAML. + + These configuration keys are supported: + + - entryPoint + - issuer + - cert + - privateKey + - decryptionPvk + - signatureAlgorithm + - digestAlgorithm + + As part of this change, there is also a fix to the redirection behaviour when doing load balancing and HTTPS termination - the application's baseUrl is used to generate the callback URL. For properly configured Backstage installations, no changes are necessary, and the baseUrl is respected. + +- Updated dependencies [5ecd50f8a] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/backend-common@0.4.2 + ## 0.2.8 ### Patch Changes diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index ee6e84cded..1fea3346a7 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -10,7 +10,7 @@ to the appropriate provider in the backend. ## Local development Choose your OAuth Providers, replace `x` with actual value and then start backend: -Example for Google Oauth Provider at root directory: +Example for Google OAuth Provider at root directory: ```bash export AUTH_GOOGLE_CLIENT_ID=x @@ -89,8 +89,16 @@ export AUTH_GITLAB_CLIENT_SECRET=x ### Okta +Add a new Okta application using the following URI conventions: + +Login redirect URI's: `http://localhost:7000/api/auth/okta/handler/frame` +Logout redirect URI's: `http://localhost:7000/api/auth/okta/logout` +Initiate login URI's: `http://localhost:7000/api/auth/okta/start` + +Then configure the following environment variables to be used in the `app-config.yaml` file: + ```bash -export AUTH_OKTA_AUDIENCE=x +export AUTH_OKTA_AUDIENCE=https://example.okta.com export AUTH_OKTA_CLIENT_ID=x export AUTH_OKTA_CLIENT_SECRET=x ``` diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index f7e1da9b96..84f71b52b9 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -46,11 +46,12 @@ export interface Config { }; saml?: { entryPoint: string; + logoutUrl?: string; issuer: string; cert?: string; privateKey?: string; decryptionPvk?: string; - signatureAlgorithm?: 'sha1' | 'sha256' | 'sha512'; + signatureAlgorithm?: 'sha256' | 'sha512'; digestAlgorithm?: string; }; okta?: { @@ -71,6 +72,10 @@ export interface Config { onelogin?: { development: { [key: string]: string }; }; + awsalb?: { + issuer?: string; + region: string; + }; }; }; } diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index a91ec04ad9..e87c2b4a90 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.2.8", + "version": "0.2.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-client": "^0.3.4", - "@backstage/catalog-model": "^0.6.0", + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-client": "^0.3.5", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "compression": "^1.7.4", @@ -49,6 +49,7 @@ "knex": "^0.21.6", "moment": "^2.26.0", "morgan": "^1.10.0", + "node-cache": "^5.1.2", "openid-client": "^4.2.1", "passport": "^0.4.1", "passport-github2": "^0.1.12", @@ -58,24 +59,24 @@ "passport-oauth2": "^1.5.0", "passport-okta-oauth": "^0.0.1", "passport-onelogin-oauth": "^0.0.1", - "passport-saml": "^1.3.5, <1.4.0", + "passport-saml": "^2.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@backstage/cli": "^0.6.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", "@types/jwt-decode": "^3.1.0", - "@types/nock": "^11.1.0", - "@types/openid-client": "^3.7.0", "@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", + "@types/passport-strategy": "^0.2.35", + "@types/xml2js": "^0.4.7", "msw": "^0.21.2", "nock": "^13.0.5" }, diff --git a/plugins/auth-backend/src/identity/IdentityClient.test.ts b/plugins/auth-backend/src/identity/IdentityClient.test.ts new file mode 100644 index 0000000000..5f317aa0c2 --- /dev/null +++ b/plugins/auth-backend/src/identity/IdentityClient.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JWT, JSONWebKey } from 'jose'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { + getVoidLogger, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { IdentityClient } from './IdentityClient'; +import { MemoryKeyStore } from './MemoryKeyStore'; +import { TokenFactory } from './TokenFactory'; +import { KeyStore } from './types'; + +const logger = getVoidLogger(); + +function jwtKid(jwt: string): string { + const { header } = JWT.decode(jwt, { complete: true }) as { + header: { kid: string }; + }; + return header.kid; +} + +const server = setupServer(); +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discovery: PluginEndpointDiscovery = { + async getBaseUrl() { + return mockBaseUrl; + }, + async getExternalBaseUrl() { + return mockBaseUrl; + }, +}; + +describe('IdentityClient', () => { + let client: IdentityClient; + let factory: TokenFactory; + let keyStore: KeyStore; + const keyDurationSeconds = 5; + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); + afterEach(() => server.resetHandlers()); + + beforeEach(() => { + client = new IdentityClient({ discovery, issuer: mockBaseUrl }); + keyStore = new MemoryKeyStore(); + factory = new TokenFactory({ + issuer: mockBaseUrl, + keyStore: keyStore, + keyDurationSeconds, + logger, + }); + }); + + describe('authenticate', () => { + beforeEach(() => { + server.use( + rest.get( + `${mockBaseUrl}/.well-known/jwks.json`, + async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + return res(ctx.json(keys)); + }, + ), + ); + }); + + it('should use the correct endpoint', async () => { + await factory.issueToken({ claims: { sub: 'foo' } }); + const keys = await factory.listPublicKeys(); + const response = await client.listPublicKeys(); + expect(response).toEqual(keys); + }); + + it('should throw on undefined header', async () => { + return expect(async () => { + await client.authenticate(undefined); + }).rejects.toThrow(); + }); + + it('should accept fresh token', async () => { + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await client.authenticate(token); + expect(response).toEqual({ id: 'foo', idToken: token }); + }); + + it('should throw on incorrect issuer', async () => { + const hackerFactory = new TokenFactory({ + issuer: 'hacker', + keyStore, + keyDurationSeconds, + logger, + }); + return expect(async () => { + const token = await hackerFactory.issueToken({ + claims: { sub: 'foo' }, + }); + await client.authenticate(token); + }).rejects.toThrow(); + }); + + it('should throw on expired token', async () => { + return expect(async () => { + const fixedTime = Date.now(); + jest + .spyOn(Date, 'now') + .mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2); + const token = await factory.issueToken({ + claims: { sub: 'foo' }, + }); + jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); + await client.authenticate(token); + }).rejects.toThrow(); + }); + + it('should throw on incorrect signing key', async () => { + const hackerFactory = new TokenFactory({ + issuer: mockBaseUrl, + keyStore: new MemoryKeyStore(), + keyDurationSeconds, + logger, + }); + return expect(async () => { + const token = await hackerFactory.issueToken({ + claims: { sub: 'foo' }, + }); + await client.authenticate(token); + }).rejects.toThrow(); + }); + + it('should accept token from new key', async () => { + const fixedTime = Date.now(); + jest + .spyOn(Date, 'now') + .mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2); + const token1 = await factory.issueToken({ claims: { sub: 'foo1' } }); + try { + // This throws as token has already expired + await client.authenticate(token1); + } catch (_err) { + // Ignore thrown error + } + // Move forward in time where the signing key has been rotated + jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await client.authenticate(token); + expect(response).toEqual({ id: 'foo', idToken: token }); + }); + + it('should not be fooled by the none algorithm', async () => { + return expect(async () => { + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const header = btoa( + JSON.stringify({ alg: 'none', kid: jwtKid(token) }), + ); + const payload = btoa( + JSON.stringify({ + iss: mockBaseUrl, + sub: 'foo', + aud: 'backstage', + iat: Date.now() / 1000, + exp: Date.now() / 1000 + 60000, + }), + ); + const fakeToken = `${header}.${payload}.`; + return await client.authenticate(fakeToken); + }).rejects.toThrow(); + }); + }); + + describe('getBearerToken', () => { + it('should return undefined on undefined input', async () => { + const token = IdentityClient.getBearerToken(undefined); + expect(token).toBeUndefined(); + }); + + it('should return undefined on malformed input', async () => { + const token = IdentityClient.getBearerToken('malformed'); + expect(token).toBeUndefined(); + }); + + it('should return undefined on unexpected scheme', async () => { + const token = IdentityClient.getBearerToken('Basic token'); + expect(token).toBeUndefined(); + }); + + it('should return Bearer token', async () => { + const token = IdentityClient.getBearerToken('Bearer token'); + expect(token).toEqual('token'); + }); + + it('should return Bearer token despite extra space', async () => { + const token = IdentityClient.getBearerToken('Bearer \n token '); + expect(token).toEqual('token'); + }); + + it('should return Bearer token despite unconventionial case', async () => { + const token = IdentityClient.getBearerToken('bEARER token'); + expect(token).toEqual('token'); + }); + }); + + describe('listPublicKeys', () => { + const defaultServiceResponse: { + keys: JSONWebKey[]; + } = { + keys: [ + { + crv: 'P-256', + x: 'JWy80Goa-8C3oaeDLnk0ANVPPMfI9T3u_T5T7W2b_ls', + y: 'Ge6jAhCDW1PFBfme2RA5ZsXN0cESiCwW29LMRPX5wkw', + kty: 'EC', + kid: 'kid-a', + alg: 'ES256', + use: 'sig', + }, + ], + }; + + beforeEach(() => { + server.use( + rest.get(`${mockBaseUrl}/.well-known/jwks.json`, (_, res, ctx) => { + return res(ctx.json(defaultServiceResponse)); + }), + ); + }); + + it('should use the correct endpoint', async () => { + const response = await client.listPublicKeys(); + expect(response).toEqual(defaultServiceResponse); + }); + }); +}); diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/plugins/auth-backend/src/identity/IdentityClient.ts new file mode 100644 index 0000000000..970c30dd40 --- /dev/null +++ b/plugins/auth-backend/src/identity/IdentityClient.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fetch from 'cross-fetch'; +import { JWK, JWT, JWKS, JSONWebKey } from 'jose'; +import { BackstageIdentity } from '../providers'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +const CLOCK_MARGIN_S = 10; + +/** + * A identity client to interact with auth-backend + * and authenticate backstage identity tokens + * + * @experimental This is not a stable API yet + */ +export class IdentityClient { + private readonly discovery: PluginEndpointDiscovery; + private readonly issuer: string; + private keyStore: JWKS.KeyStore; + private keyStoreUpdated: number; + + constructor(options: { discovery: PluginEndpointDiscovery; issuer: string }) { + this.discovery = options.discovery; + this.issuer = options.issuer; + this.keyStore = new JWKS.KeyStore(); + this.keyStoreUpdated = 0; + } + + /** + * Verifies the given backstage identity token + * Returns a BackstageIdentity (user) matching the token. + * The method throws an error if verification fails. + */ + async authenticate(token: string | undefined): Promise { + // Extract token from header + if (!token) { + throw new Error('No token specified'); + } + // Get signing key matching token + const key = await this.getKey(token); + if (!key) { + throw new Error('No signing key matching token found'); + } + // Verify token claims and signature + // Note: Claims must match those set by TokenFactory when issuing tokens + // Note: verify throws if verification fails + const decoded = JWT.IdToken.verify(token, key, { + algorithms: ['ES256'], + audience: 'backstage', + issuer: this.issuer, + }) as { sub: string }; + // Verified, return the matching user as BackstageIdentity + // TODO: Settle internal user format/properties + const user: BackstageIdentity = { + id: decoded.sub, + idToken: token, + }; + return user; + } + + /** + * Parses the given authorization header and returns + * the bearer token, or null if no bearer token is given + */ + static getBearerToken( + authorizationHeader: string | undefined, + ): string | undefined { + if (typeof authorizationHeader !== 'string') { + return undefined; + } + const matches = authorizationHeader.match(/Bearer\s+(\S+)/i); + return matches?.[1]; + } + + /** + * Returns the public signing key matching the given jwt token, + * or null if no matching key was found + */ + private async getKey(rawJwtToken: string): Promise { + const { header, payload } = JWT.decode(rawJwtToken, { + complete: true, + }) as { + header: { kid: string }; + payload: { iat: number }; + }; + + // Refresh public keys if needed + // Add a small margin in case clocks are out of sync + const keyStoreHasKey = !!this.keyStore.get({ kid: header.kid }); + const issuedAfterLastRefresh = + payload?.iat && payload.iat > this.keyStoreUpdated - CLOCK_MARGIN_S; + if (!keyStoreHasKey && issuedAfterLastRefresh) { + await this.refreshKeyStore(); + } + + return this.keyStore.get({ kid: header.kid }); + } + + /** + * Lists public part of keys used to sign Backstage Identity tokens + */ + async listPublicKeys(): Promise<{ + keys: JSONWebKey[]; + }> { + const url = `${await this.discovery.getBaseUrl( + 'auth', + )}/.well-known/jwks.json`; + const response = await fetch(url); + + if (!response.ok) { + const payload = await response.text(); + const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + throw new Error(message); + } + + const publicKeys: { keys: JSONWebKey[] } = await response.json(); + + return publicKeys; + } + + /** + * Fetches public keys and caches them locally + */ + private async refreshKeyStore(): Promise { + const now = Date.now() / 1000; + const publicKeys = await this.listPublicKeys(); + this.keyStore = JWKS.asKeyStore({ + keys: publicKeys.keys.map(key => key as JSONWebKey), + }); + this.keyStoreUpdated = now; + } +} diff --git a/plugins/auth-backend/src/identity/MemoryKeyStore.ts b/plugins/auth-backend/src/identity/MemoryKeyStore.ts new file mode 100644 index 0000000000..b35002b146 --- /dev/null +++ b/plugins/auth-backend/src/identity/MemoryKeyStore.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 { utc } from 'moment'; +import { KeyStore, AnyJWK, StoredKey } from './types'; + +export class MemoryKeyStore implements KeyStore { + private readonly keys = new Map< + string, + { createdAt: moment.Moment; key: string } + >(); + + async addKey(key: AnyJWK): Promise { + this.keys.set(key.kid, { + createdAt: utc(), + key: JSON.stringify(key), + }); + } + + async removeKeys(kids: string[]): Promise { + for (const kid of kids) { + this.keys.delete(kid); + } + } + + async listKeys(): Promise<{ items: StoredKey[] }> { + return { + items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({ + createdAt, + key: JSON.parse(keyStr), + })), + }; + } +} diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 8b719799b1..0c7febe616 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -14,43 +14,13 @@ * limitations under the License. */ -import { utc } from 'moment'; +import { MemoryKeyStore } from './MemoryKeyStore'; import { TokenFactory } from './TokenFactory'; import { getVoidLogger } from '@backstage/backend-common'; -import { KeyStore, AnyJWK, StoredKey } from './types'; import { JWKS, JSONWebKey, JWT } from 'jose'; const logger = getVoidLogger(); -class MemoryKeyStore implements KeyStore { - private readonly keys = new Map< - string, - { createdAt: moment.Moment; key: string } - >(); - - async addKey(key: AnyJWK): Promise { - this.keys.set(key.kid, { - createdAt: utc(), - key: JSON.stringify(key), - }); - } - - async removeKeys(kids: string[]): Promise { - for (const kid of kids) { - this.keys.delete(kid); - } - } - - async listKeys(): Promise<{ items: StoredKey[] }> { - return { - items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({ - createdAt, - key: JSON.parse(keyStr), - })), - }; - } -} - function jwtKid(jwt: string): string { const { header } = JWT.decode(jwt, { complete: true }) as { header: { kid: string }; diff --git a/plugins/auth-backend/src/identity/index.ts b/plugins/auth-backend/src/identity/index.ts index 19eec94556..a76dcea440 100644 --- a/plugins/auth-backend/src/identity/index.ts +++ b/plugins/auth-backend/src/identity/index.ts @@ -15,6 +15,7 @@ */ export { createOidcRouter } from './router'; +export { IdentityClient } from './IdentityClient'; export { TokenFactory } from './TokenFactory'; export { DatabaseKeyStore } from './DatabaseKeyStore'; export type { KeyStore, TokenIssuer, TokenParams } from './types'; diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index 3d3f059dcc..a352362089 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -15,6 +15,7 @@ */ export * from './service/router'; +export { IdentityClient } from './identity'; export * from './providers'; // flow package provides 2 functions diff --git a/plugins/auth-backend/src/providers/aws-alb/index.ts b/plugins/auth-backend/src/providers/aws-alb/index.ts new file mode 100644 index 0000000000..f8b5c9e5d7 --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { createAwsAlbProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts new file mode 100644 index 0000000000..d05b6bbfaf --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { JWT } from 'jose'; + +import { AwsAlbAuthProvider } from './provider'; +import { AuthResponse } from '../types'; + +const jwtMock = JWT as jest.Mocked; + +const mockKey = async () => { + return `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I +yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw== +-----END PUBLIC KEY----- +`; +}; + +jest.mock('jose'); + +jest.mock('cross-fetch', () => ({ + __esModule: true, + default: async () => { + return { + text: async () => { + return mockKey(); + }, + }; + }, +})); + +const identityResolutionCallbackMock = async (): Promise> => { + return { + backstageIdentity: { + id: 'foo', + idToken: '', + }, + profile: { + displayName: 'Foo Bar', + }, + providerInfo: {}, + }; +}; + +const identityResolutionCallbackRejectedMock = async (): Promise< + AuthResponse +> => { + throw new Error('failed'); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('AwsALBAuthProvider', () => { + const catalogApi = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn(), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + + const mockResponseSend = jest.fn(); + const mockRequest = ({ + header: jest.fn(() => { + return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo'; + }), + } as unknown) as express.Request; + const mockRequestWithoutJwt = ({ + header: jest.fn(() => { + return undefined; + }), + } as unknown) as express.Request; + const mockResponse = ({ + header: () => jest.fn(), + send: mockResponseSend, + } as unknown) as express.Response; + + describe('should transform to type OAuthResponse', () => { + it('when JWT is valid and identity is resolved successfully', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); + + jwtMock.verify.mockImplementationOnce(() => ({ + sub: 'foo', + })); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual({ + backstageIdentity: { + id: 'foo', + idToken: '', + }, + profile: { + displayName: 'Foo Bar', + }, + providerInfo: {}, + }); + }); + }); + describe('should fail when', () => { + it('JWT is missing', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); + + await provider.refresh(mockRequestWithoutJwt, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + + it('JWT is invalid', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); + + jwtMock.verify.mockImplementationOnce(() => { + throw new Error('bad JWT'); + }); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + + it('issuer is invalid', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foobar', + }); + + jwtMock.verify.mockReturnValueOnce({}); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + + it('identity resolution callback rejects', async () => { + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackRejectedMock, + issuer: 'foo', + }); + + jwtMock.verify.mockReturnValueOnce({}); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts new file mode 100644 index 0000000000..61ea10947e --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -0,0 +1,127 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + AuthProviderFactoryOptions, + AuthProviderRouteHandlers, + ExperimentalIdentityResolver, +} from '../types'; +import express from 'express'; +import fetch from 'cross-fetch'; +import * as crypto from 'crypto'; +import { KeyObject } from 'crypto'; +import { Logger } from 'winston'; +import NodeCache from 'node-cache'; +import { JWT } from 'jose'; +import { CatalogApi } from '@backstage/catalog-client'; + +const ALB_JWT_HEADER = 'x-amzn-oidc-data'; +/** + * A callback function that receives a verified JWT and returns a UserEntity + * @param {payload} The verified JWT payload + */ +type AwsAlbAuthProviderOptions = { + region: string; + issuer?: string; + identityResolutionCallback: ExperimentalIdentityResolver; +}; +export const getJWTHeaders = (input: string) => { + const encoded = input.split('.')[0]; + return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); +}; + +export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { + private logger: Logger; + private readonly catalogClient: CatalogApi; + private options: AwsAlbAuthProviderOptions; + private readonly keyCache: NodeCache; + + constructor( + logger: Logger, + catalogClient: CatalogApi, + options: AwsAlbAuthProviderOptions, + ) { + this.logger = logger; + this.catalogClient = catalogClient; + this.options = options; + this.keyCache = new NodeCache({ stdTTL: 3600 }); + } + frameHandler(): Promise { + return Promise.resolve(undefined); + } + + async refresh(req: express.Request, res: express.Response): Promise { + const jwt = req.header(ALB_JWT_HEADER); + if (jwt !== undefined) { + try { + const headers = getJWTHeaders(jwt); + const key = await this.getKey(headers.kid); + const payload = JWT.verify(jwt, key); + + if (this.options.issuer && headers.iss !== this.options.issuer) { + throw new Error('issuer mismatch on JWT'); + } + + const resolvedEntity = await this.options.identityResolutionCallback( + payload, + this.catalogClient, + ); + res.send(resolvedEntity); + } catch (e) { + this.logger.error('exception occurred during JWT processing', e); + res.send(401); + } + } else { + res.send(401); + } + } + + start(): Promise { + return Promise.resolve(undefined); + } + + async getKey(keyId: string): Promise { + const optionalCacheKey = this.keyCache.get(keyId); + if (optionalCacheKey) { + return crypto.createPublicKey(optionalCacheKey); + } + const keyText: string = await fetch( + `https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`, + ).then(response => response.text()); + const keyValue = crypto.createPublicKey(keyText); + this.keyCache.set(keyId, keyValue.export({ format: 'pem', type: 'spki' })); + return keyValue; + } +} + +export const createAwsAlbProvider = ({ + logger, + catalogApi, + config, + identityResolver, +}: AuthProviderFactoryOptions) => { + const region = config.getString('region'); + const issuer = config.getOptionalString('iss'); + if (identityResolver !== undefined) { + return new AwsAlbAuthProvider(logger, catalogApi, { + region, + issuer, + identityResolutionCallback: identityResolver, + }); + } + throw new Error( + 'Identity resolver is required to use this authentication provider', + ); +}; diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 7bad4f4d81..619fb1c706 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -25,6 +25,7 @@ import { createAuth0Provider } from './auth0'; import { createMicrosoftProvider } from './microsoft'; import { createOneLoginProvider } from './onelogin'; import { AuthProviderFactory } from './types'; +import { createAwsAlbProvider } from './aws-alb'; export const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -37,4 +38,5 @@ export const factories: { [providerId: string]: AuthProviderFactory } = { oauth2: createOAuth2Provider, oidc: createOidcProvider, onelogin: createOneLoginProvider, + awsalb: createAwsAlbProvider, }; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts index f2bd8dcb90..fcbe9d7abc 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -71,6 +71,7 @@ describe('OidcAuthProvider', () => { const jwt = { sub: 'alice', iss: 'https://oidc.test', + iat: Date.now(), aud: clientMetadata.clientId, exp: Date.now() + 10000, }; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 4cdee1da0f..74541d8294 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -15,8 +15,8 @@ */ import express from 'express'; +import { SamlConfig } from 'passport-saml/lib/passport-saml/types'; import { - SamlConfig, Strategy as SamlStrategy, Profile as SamlProfile, VerifyWithoutRequest, @@ -129,6 +129,7 @@ export const createSamlProvider: AuthProviderFactory = ({ const opts = { callbackUrl: `${globalConfig.baseUrl}/${providerId}/handler/frame`, entryPoint: config.getString('entryPoint'), + logoutUrl: config.getOptionalString('logoutUrl'), issuer: config.getString('issuer'), cert: config.getOptionalString('cert'), privateCert: config.getOptionalString('privateKey'), @@ -142,5 +143,10 @@ export const createSamlProvider: AuthProviderFactory = ({ appUrl: globalConfig.appUrl, }; + // passport-saml will return an error if the `cert` key is set, and the value is empty. + // Since we read from config (such as environment variables) an empty string should be equal to being unset. + if (!opts.cert) { + delete opts.cert; + } return new SamlAuthProvider(opts); }; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index a40f2a96ee..1a4e7b118a 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -112,6 +112,19 @@ export interface AuthProviderRouteHandlers { logout?(req: express.Request, res: express.Response): Promise; } +/** + * EXPERIMENTAL - this will almost certainly break in a future release. + * + * Used to resolve an identity from auth information in some auth providers. + */ +export type ExperimentalIdentityResolver = ( + /** + * An object containing information specific to the auth provider. + */ + payload: object, + catalogApi: CatalogApi, +) => Promise>; + export type AuthProviderFactoryOptions = { providerId: string; globalConfig: AuthProviderConfig; @@ -120,6 +133,7 @@ export type AuthProviderFactoryOptions = { tokenIssuer: TokenIssuer; discovery: PluginEndpointDiscovery; catalogApi: CatalogApi; + identityResolver?: ExperimentalIdentityResolver; }; export type AuthProviderFactory = ( diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 8e941457a9..eb256d2c08 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,111 @@ # @backstage/plugin-catalog-backend +## 0.6.0 + +### Minor Changes + +- 3149bfe63: Make use of the `resolveUrl` facility of the `integration` package. + + Also rename the `LocationRefProcessor` to `LocationEntityProcessor`, to match the file name. This constitutes an interface change since the class is exported, but it is unlikely to be consumed outside of the package since it sits comfortably with the other default processors inside the catalog builder. + +### Patch Changes + +- 24e47ef1e: Throw `NotAllowedError` when registering locations with entities of disallowed kinds +- Updated dependencies [c4abcdb60] +- Updated dependencies [2430ee7c2] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [064c513e1] +- Updated dependencies [7881f2117] +- Updated dependencies [3149bfe63] +- Updated dependencies [2e62aea6f] +- Updated dependencies [11cb5ef94] + - @backstage/integration@0.3.2 + - @backstage/backend-common@0.5.2 + - @backstage/catalog-model@0.7.1 + +## 0.5.5 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- a91aa6bf2: Support supplying a custom catalog descriptor file parser +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + +## 0.5.4 + +### Patch Changes + +- def2307f3: Adds a `backstage.io/managed-by-origin-location` annotation to all entities. It links to the + location that was registered to the catalog and which emitted this entity. It has a different + semantic than the existing `backstage.io/managed-by-location` annotation, which tells the direct + parent location that created this entity. + + Consider this example: The Backstage operator adds a location of type `github-org` in the + `app-config.yaml`. This setting will be added to a `bootstrap:boostrap` location. The processor + discovers the entities in the following branch + `Location bootstrap:bootstrap -> Location github-org:… -> User xyz`. The user `xyz` will be: + + ```yaml + apiVersion: backstage.io/v1alpha1 + kind: User + metadata: + name: xyz + annotations: + # This entity was added by the 'github-org:…' location + backstage.io/managed-by-location: github-org:… + # The entity was added because the 'bootstrap:boostrap' was added to the catalog + backstage.io/managed-by-origin-location: bootstrap:bootstrap + # ... + spec: + # ... + ``` + +- 318a6af9f: Change AWS Account type from Component to Resource +- ac7be581a: Refuse to remove the bootstrap location +- ad838c02f: Reduce log noise on locations refresh +- f9ba00a1c: Update the @azure/msal-node dependency to 1.0.0-beta.3. +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + +## 0.5.3 + +### Patch Changes + +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- ade6b3bdf: AWS SDK version bump for Catalog Backend. +- abbee6fff: Implement System, Domain and Resource entity kinds. +- 147fadcb9: Add subcomponentOf to Component kind to represent subsystems of larger components. +- Updated dependencies [f3b064e1c] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/backend-common@0.4.3 + +## 0.5.2 + +### Patch Changes + +- 99be3057c: Fixed a bug where the catalog would read back all entities when adding a location that already exists. +- 49d2016a4: Change `location_update_log` columns from `nvarchar(255)` to `text` +- 73e75ea0a: Add processor for ingesting AWS accounts from AWS Organizations +- 071711d70: Remove `sqlite3` as a dependency. You may need to add `sqlite3` as a dependency of your backend if you were relying on this indirect dependency. +- Updated dependencies [5ecd50f8a] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/backend-common@0.4.2 + ## 0.5.1 ### Patch Changes @@ -40,7 +146,7 @@ ### Patch Changes -- c6eeefa35: Add support for Github Enterprise in GitHubOrgReaderProcessor so you can properly ingest users of a GHE organization. +- c6eeefa35: Add support for GitHub Enterprise in GitHubOrgReaderProcessor so you can properly ingest users of a GHE organization. - fb386b760: Break the refresh loop into several smaller transactions - 7c3ffc0cd: Support `profile` of groups including `displayName`, `email`, and `picture` in `LdapOrgReaderProcessor`. The source fields for them can be configured in the diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 1a43dd6d62..bb76441f3f 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.5.1", + "version": "0.6.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,28 +29,28 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@azure/msal-node": "^1.0.0-alpha.8", - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@aws-sdk/client-organizations": "^3.2.0", + "@azure/msal-node": "^1.0.0-beta.3", + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@octokit/graphql": "^4.5.6", + "@backstage/integration": "^0.3.2", + "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", "@types/ldapjs": "^1.0.9", - "aws-sdk": "^2.817.0", "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "knex": "^0.21.6", "ldapjs": "^2.2.0", "lodash": "^4.17.15", "morgan": "^1.10.0", "p-limit": "^3.0.2", "qs": "^6.9.4", - "sqlite3": "^5.0.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yaml": "^1.9.2", @@ -58,7 +58,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.3", + "@backstage/cli": "^0.6.0", "@backstage/test-utils": "^0.1.6", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", @@ -67,6 +67,7 @@ "@types/uuid": "^8.0.0", "@types/yup": "^0.29.8", "msw": "^0.21.2", + "sqlite3": "^5.0.0", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 76fc63c9b3..b03183cf34 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -124,7 +124,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // produce tens of thousands of entities, and those are too large batch // sizes to reasonably send to the database. const batches = Object.values(requestsByKindAndNamespace) - .map(requests => chunk(requests, BATCH_SIZE)) + .map(request => chunk(request, BATCH_SIZE)) .flat(); // Bound the number of concurrent batches. We want a bit of concurrency for diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index 130ed41eb1..b319bb832d 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -137,6 +137,22 @@ describe('CommonDatabase', () => { await expect(db.location(location.id)).rejects.toThrow(/Found no location/); }); + it('refuses to remove the bootstrap location', async () => { + const input: Location = { + id: 'dd12620d-0436-422f-93bd-929aa0788123', + type: 'bootstrap', + target: 'bootstrap', + }; + + const output = await db.transaction( + async tx => await db.addLocation(tx, input), + ); + + await expect( + db.transaction(async tx => await db.removeLocation(tx, output.id)), + ).rejects.toThrow(ConflictError); + }); + describe('addEntities', () => { it('happy path: adds entities to empty database', async () => { const result = await db.transaction(tx => diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index d641cbb7b6..2edc0ca5b8 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -330,15 +330,21 @@ export class CommonDatabase implements Database { async removeLocation(txOpaque: Transaction, id: string): Promise { const tx = txOpaque as Knex.Transaction; + const locations = await tx('locations') + .where({ id }) + .select(); + if (!locations.length) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + + if (locations[0].type === 'bootstrap') { + throw new ConflictError('You may not delete the bootstrap location.'); + } + await tx('entities') .where({ location_id: id }) .update({ location_id: null }); - - const result = await tx('locations').where({ id }).del(); - - if (!result) { - throw new NotFoundError(`Found no location with ID ${id}`); - } + await tx('locations').where({ id }).del(); } async location(id: string): Promise { diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index b7e9c48fe5..467c0eb2e8 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -130,7 +130,7 @@ export class HigherOrderOperations implements HigherOrderOperation { `Locations Refresh: Refreshing location ${location.type}:${location.target}`, ); try { - await this.refreshSingleLocation(location); + await this.refreshSingleLocation(location, logger); await this.locationsCatalog.logUpdateSuccess(location.id, undefined); } catch (e) { logger.warn( @@ -148,8 +148,12 @@ export class HigherOrderOperations implements HigherOrderOperation { } // Performs a full refresh of a single location - private async refreshSingleLocation(location: Location) { + private async refreshSingleLocation( + location: Location, + optionalLogger?: Logger, + ) { let startTimestamp = process.hrtime(); + const logger = optionalLogger || this.logger; const readerOutput = await this.locationReader.read({ type: location.type, @@ -157,12 +161,12 @@ export class HigherOrderOperations implements HigherOrderOperation { }); for (const item of readerOutput.errors) { - this.logger.warn( + logger.warn( `Failed item in location ${item.location.type}:${item.location.target}, ${item.error.stack}`, ); } - this.logger.info( + logger.info( `Read ${readerOutput.entities.length} entities from location ${ location.type }:${location.target} in ${durationText(startTimestamp)}`, @@ -186,14 +190,14 @@ export class HigherOrderOperations implements HigherOrderOperation { throw e; } - this.logger.info(`Posting update success markers`); + logger.debug(`Posting update success markers`); await this.locationsCatalog.logUpdateSuccess( location.id, readerOutput.entities.map(e => e.entity.metadata.name), ); - this.logger.info( + logger.info( `Wrote ${readerOutput.entities.length} entities from location ${ location.type }:${location.target} in ${durationText(startTimestamp)}`, diff --git a/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts b/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts index 97df326fc5..bc81cf14f8 100644 --- a/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts +++ b/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts @@ -15,7 +15,7 @@ */ import { Logger } from 'winston'; -import parseGitUri from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; import { AnalyzeLocationRequest, AnalyzeLocationResponse, @@ -31,7 +31,7 @@ export class RepoLocationAnalyzer implements LocationAnalyzer { async analyzeLocation( request: AnalyzeLocationRequest, ): Promise { - const { owner, name, source } = parseGitUri(request.location.target); + const { owner, name, source } = parseGitUrl(request.location.target); const entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index a9248bb3fb..be88aa2810 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { UrlReader } from '@backstage/backend-common'; +import { NotAllowedError, UrlReader } from '@backstage/backend-common'; import { Entity, EntityPolicy, @@ -32,6 +32,7 @@ import { CatalogProcessorEntityResult, CatalogProcessorErrorResult, CatalogProcessorLocationResult, + CatalogProcessorParser, CatalogProcessorResult, } from './processors/types'; import { LocationReader, ReadLocationResult } from './types'; @@ -41,6 +42,7 @@ const MAX_DEPTH = 10; type Options = { reader: UrlReader; + parser: CatalogProcessorParser; logger: Logger; config: Config; processors: CatalogProcessor[]; @@ -78,13 +80,17 @@ export class LocationReaders implements LocationReader { if (rulesEnforcer.isAllowed(item.entity, item.location)) { const relations = Array(); - const entity = await this.handleEntity(item, emitResult => { - if (emitResult.type === 'relation') { - relations.push(emitResult.relation); - return; - } - emit(emitResult); - }); + const entity = await this.handleEntity( + item, + emitResult => { + if (emitResult.type === 'relation') { + relations.push(emitResult.relation); + return; + } + emit(emitResult); + }, + location, + ); if (entity) { output.entities.push({ @@ -96,7 +102,7 @@ export class LocationReaders implements LocationReader { } else { output.errors.push({ location: item.location, - error: new Error( + error: new NotAllowedError( `Entity of kind ${item.entity.kind} is not allowed from location ${item.location.type} ${item.location.target}`, ), }); @@ -133,7 +139,16 @@ export class LocationReaders implements LocationReader { if (emitResult.type === 'relation') { throw new Error('readLocation may not emit entity relations'); } - + if ( + emitResult.type === 'location' && + emitResult.location.type === item.location.type && + emitResult.location.target === item.location.target + ) { + // Ignore self-referential locations silently (this can happen for + // example if you use a glob target like "**/*.yaml" in a Location + // entity) + return; + } emit(emitResult); }; @@ -145,6 +160,7 @@ export class LocationReaders implements LocationReader { item.location, item.optional, validatedEmit, + this.options.parser, ) ) { return; @@ -165,6 +181,7 @@ export class LocationReaders implements LocationReader { private async handleEntity( item: CatalogProcessorEntityResult, emit: CatalogProcessorEmit, + originLocation: LocationSpec, ): Promise { const { processors, logger } = this.options; @@ -185,6 +202,7 @@ export class LocationReaders implements LocationReader { current, item.location, emit, + originLocation, ); } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`; diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts new file mode 100644 index 0000000000..cfd98e9e6a --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; + +describe('AnnotateLocationEntityProcessor', () => { + describe('preProcessEntity', () => { + it('adds annotations', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + }, + }; + + const location: LocationSpec = { + type: 'url', + target: 'my-location', + }; + const originLocation: LocationSpec = { + type: 'url', + target: 'my-origin-location', + }; + + const processor = new AnnotateLocationEntityProcessor(); + + expect( + await processor.preProcessEntity( + entity, + location, + () => {}, + originLocation, + ), + ).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + annotations: { + 'backstage.io/managed-by-location': 'url:my-location', + 'backstage.io/managed-by-origin-location': 'url:my-origin-location', + }, + }, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts index ea8afcddc5..b40378226a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts @@ -14,20 +14,28 @@ * limitations under the License. */ -import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { + Entity, + LOCATION_ANNOTATION, + LocationSpec, + ORIGIN_LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; import lodash from 'lodash'; -import { CatalogProcessor } from './types'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; export class AnnotateLocationEntityProcessor implements CatalogProcessor { async preProcessEntity( entity: Entity, location: LocationSpec, + _: CatalogProcessorEmit, + originLocation: LocationSpec, ): Promise { return lodash.merge( { metadata: { annotations: { - 'backstage.io/managed-by-location': `${location.type}:${location.target}`, + [LOCATION_ANNOTATION]: `${location.type}:${location.target}`, + [ORIGIN_LOCATION_ANNOTATION]: `${originLocation.type}:${originLocation.target}`, }, }, }, diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts index eb5e634d66..28200021cc 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts @@ -27,29 +27,25 @@ describe('AwsOrganizationCloudAccountProcessor', () => { afterEach(() => jest.resetAllMocks()); it('generates component entities for accounts', async () => { - listAccounts.mockImplementation(() => { - return { - async promise() { - return { - Accounts: [ - { - Arn: - 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', - Name: 'testaccount', - }, - ], - NextToken: undefined, - }; - }, - }; - }); + listAccounts.mockImplementation(() => + Promise.resolve({ + Accounts: [ + { + Arn: + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + Name: 'testaccount', + }, + ], + NextToken: undefined, + }), + ); await processor.readLocation(location, false, emit); expect(emit).toBeCalledWith({ type: 'entity', location, entity: { apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', + kind: 'Resource', metadata: { annotations: { 'amazonaws.com/arn': @@ -62,7 +58,6 @@ describe('AwsOrganizationCloudAccountProcessor', () => { }, spec: { type: 'cloud-account', - lifecycle: 'unknown', owner: 'unknown', }, }, @@ -70,36 +65,35 @@ describe('AwsOrganizationCloudAccountProcessor', () => { }); it('filters out accounts not in specified location target', async () => { - const location = { type: 'aws-cloud-accounts', target: 'o-1vl18kc5a3' }; - listAccounts.mockImplementation(() => { - return { - async promise() { - return { - Accounts: [ - { - Arn: - 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', - Name: 'testaccount', - }, - { - Arn: - 'arn:aws:organizations::192594491037:account/o-zzzzzzzzz/957140518395', - Name: 'testaccount2', - }, - ], - NextToken: undefined, - }; - }, - }; - }); - await processor.readLocation(location, false, emit); + const locationTest = { + type: 'aws-cloud-accounts', + target: 'o-1vl18kc5a3', + }; + listAccounts.mockImplementation(() => + Promise.resolve({ + Accounts: [ + { + Arn: + 'arn:aws:organizations::192594491037:account/o-1vl18kc5a3/957140518395', + Name: 'testaccount', + }, + { + Arn: + 'arn:aws:organizations::192594491037:account/o-zzzzzzzzz/957140518395', + Name: 'testaccount2', + }, + ], + NextToken: undefined, + }), + ); + await processor.readLocation(locationTest, false, emit); expect(emit).toBeCalledTimes(1); expect(emit).toBeCalledWith({ type: 'entity', - location, + location: locationTest, entity: { apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', + kind: 'Resource', metadata: { annotations: { 'amazonaws.com/arn': @@ -112,7 +106,6 @@ describe('AwsOrganizationCloudAccountProcessor', () => { }, spec: { type: 'cloud-account', - lifecycle: 'unknown', owner: 'unknown', }, }, diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts index f019f5207b..40516f88f7 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.ts @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { LocationSpec, ResourceEntityV1alpha1 } from '@backstage/catalog-model'; import { - ComponentEntityV1alpha1, - LocationSpec, -} from '@backstage/catalog-model'; -import AWS, { Organizations } from 'aws-sdk'; -import { Account, ListAccountsResponse } from 'aws-sdk/clients/organizations'; + Account, + Organizations, + ListAccountsCommandOutput, +} from '@aws-sdk/client-organizations'; import * as results from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; @@ -38,7 +38,7 @@ const ORGANIZATION_ANNOTATION: string = 'amazonaws.com/organization-id'; export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { organizations: Organizations; constructor() { - this.organizations = new AWS.Organizations({ + this.organizations = new Organizations({ region: AWS_ORGANIZATION_REGION, }); // Only available in us-east-1 } @@ -67,9 +67,9 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { let nextToken = undefined; while (isInitialAttempt || nextToken) { isInitialAttempt = false; - const orgAccounts: ListAccountsResponse = await this.organizations - .listAccounts({ NextToken: nextToken }) - .promise(); + const orgAccounts: ListAccountsCommandOutput = await this.organizations.listAccounts( + { NextToken: nextToken }, + ); if (orgAccounts.Accounts) { awsAccounts = awsAccounts.concat(orgAccounts.Accounts); } @@ -79,13 +79,13 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { return awsAccounts; } - mapAccountToComponent(account: Account): ComponentEntityV1alpha1 { + mapAccountToComponent(account: Account): ResourceEntityV1alpha1 { const { accountId, organizationId } = this.extractInformationFromArn( account.Arn as string, ); return { apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', + kind: 'Resource', metadata: { annotations: { [ACCOUNTID_ANNOTATION]: accountId, @@ -97,7 +97,6 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { }, spec: { type: 'cloud-account', - lifecycle: 'unknown', owner: 'unknown', }, }; @@ -126,7 +125,7 @@ export class AwsOrganizationCloudAccountProcessor implements CatalogProcessor { } return true; }) - .forEach((entity: ComponentEntityV1alpha1) => { + .forEach((entity: ResourceEntityV1alpha1) => { emit(results.entity(location, entity)); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index 94a08c5e73..feb4791477 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -17,7 +17,10 @@ import { ApiEntity, ComponentEntity, + DomainEntity, GroupEntity, + ResourceEntity, + SystemEntity, UserEntity, } from '@backstage/catalog-model'; import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; @@ -38,15 +41,17 @@ describe('BuiltinKindsEntityProcessor', () => { spec: { type: 'service', owner: 'o', + subcomponentOf: 's', lifecycle: 'l', providesApis: ['b'], consumesApis: ['c'], + system: 's', }, }; await processor.postProcessEntity(entity, location, emit); - expect(emit).toBeCalledTimes(6); + expect(emit).toBeCalledTimes(10); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -95,6 +100,38 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'API', namespace: 'default', name: 'c' }, }, }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'Component', namespace: 'default', name: 's' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'Component', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Component', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'System', namespace: 'default', name: 's' }, + }, + }); }); it('generates relations for api entities', async () => { @@ -107,12 +144,13 @@ describe('BuiltinKindsEntityProcessor', () => { owner: 'o', lifecycle: 'l', definition: 'd', + system: 's', }, }; await processor.postProcessEntity(entity, location, emit); - expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledTimes(4); expect(emit).toBeCalledWith({ type: 'relation', relation: { @@ -129,6 +167,150 @@ describe('BuiltinKindsEntityProcessor', () => { target: { kind: 'Group', namespace: 'default', name: 'o' }, }, }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'API', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'API', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'System', namespace: 'default', name: 's' }, + }, + }); + }); + + it('generates relations for resource entities', async () => { + const entity: ResourceEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { name: 'n' }, + spec: { + type: 'database', + owner: 'o', + system: 's', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Resource', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 's' }, + type: 'hasPart', + target: { kind: 'Resource', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Resource', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'System', namespace: 'default', name: 's' }, + }, + }); + }); + + it('generates relations for system entities', async () => { + const entity: SystemEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { name: 'n' }, + spec: { + owner: 'o', + domain: 'd', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(4); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'System', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Domain', namespace: 'default', name: 'd' }, + type: 'hasPart', + target: { kind: 'System', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'System', namespace: 'default', name: 'n' }, + type: 'partOf', + target: { kind: 'Domain', namespace: 'default', name: 'd' }, + }, + }); + }); + + it('generates relations for domain entities', async () => { + const entity: DomainEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { name: 'n' }, + spec: { + owner: 'o', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Domain', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Domain', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); }); it('generates relations for user entities', async () => { diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 6cbec5d655..ef94c54010 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -19,6 +19,8 @@ import { apiEntityV1alpha1Validator, ComponentEntity, componentEntityV1alpha1Validator, + DomainEntity, + domainEntityV1alpha1Validator, Entity, getEntityName, GroupEntity, @@ -31,11 +33,17 @@ import { RELATION_CHILD_OF, RELATION_CONSUMES_API, RELATION_HAS_MEMBER, + RELATION_HAS_PART, RELATION_MEMBER_OF, RELATION_OWNED_BY, RELATION_OWNER_OF, RELATION_PARENT_OF, + RELATION_PART_OF, RELATION_PROVIDES_API, + ResourceEntity, + resourceEntityV1alpha1Validator, + SystemEntity, + systemEntityV1alpha1Validator, templateEntityV1alpha1Validator, UserEntity, userEntityV1alpha1Validator, @@ -47,16 +55,19 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { private readonly validators = [ apiEntityV1alpha1Validator, componentEntityV1alpha1Validator, + resourceEntityV1alpha1Validator, groupEntityV1alpha1Validator, locationEntityV1alpha1Validator, templateEntityV1alpha1Validator, userEntityV1alpha1Validator, + systemEntityV1alpha1Validator, + domainEntityV1alpha1Validator, ]; async validateEntityKind(entity: Entity): Promise { for (const validator of this.validators) { - const result = await validator.check(entity); - if (result) { + const results = await validator.check(entity); + if (results) { return true; } } @@ -115,6 +126,12 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_OWNED_BY, RELATION_OWNER_OF, ); + doEmit( + component.spec.subcomponentOf, + { defaultKind: 'Component', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); doEmit( component.spec.providesApis, { defaultKind: 'API', defaultNamespace: selfRef.namespace }, @@ -127,6 +144,12 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_CONSUMES_API, RELATION_API_CONSUMED_BY, ); + doEmit( + component.spec.system, + { defaultKind: 'System', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); } /* @@ -141,6 +164,32 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { RELATION_OWNED_BY, RELATION_OWNER_OF, ); + doEmit( + api.spec.system, + { defaultKind: 'System', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); + } + + /* + * Emit relations for the Resource kind + */ + + if (entity.kind === 'Resource') { + const resource = entity as ResourceEntity; + doEmit( + resource.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + doEmit( + resource.spec.system, + { defaultKind: 'System', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); } /* @@ -177,6 +226,40 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { ); } + /* + * Emit relations for the System kind + */ + + if (entity.kind === 'System') { + const system = entity as SystemEntity; + doEmit( + system.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + doEmit( + system.spec.domain, + { defaultKind: 'Domain', defaultNamespace: selfRef.namespace }, + RELATION_PART_OF, + RELATION_HAS_PART, + ); + } + + /* + * Emit relations for the Domain kind + */ + + if (entity.kind === 'Domain') { + const domain = entity as DomainEntity; + doEmit( + domain.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + } + return entity; } } diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts index be4f15e501..eb1187eda5 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts @@ -160,7 +160,7 @@ describe('CodeOwnersProcessor', () => { const read = jest .fn() .mockResolvedValue(mockReadResult({ data: ownersText })); - const reader = { read, readTree: jest.fn() }; + const reader = { read, readTree: jest.fn(), search: jest.fn() }; const result = await findRawCodeOwners(mockLocation(), { reader, logger, @@ -170,7 +170,7 @@ describe('CodeOwnersProcessor', () => { it('should return undefined when no codeowner', async () => { const read = jest.fn().mockRejectedValue(mockReadResult()); - const reader = { read, readTree: jest.fn() }; + const reader = { read, readTree: jest.fn(), search: jest.fn() }; await expect( findRawCodeOwners(mockLocation(), { reader, logger }), @@ -184,7 +184,7 @@ describe('CodeOwnersProcessor', () => { .mockImplementationOnce(() => mockReadResult({ error: 'foo' })) .mockImplementationOnce(() => mockReadResult({ error: 'bar' })) .mockResolvedValue(mockReadResult({ data: ownersText })); - const reader = { read, readTree: jest.fn() }; + const reader = { read, readTree: jest.fn(), search: jest.fn() }; const result = await findRawCodeOwners(mockLocation(), { reader, @@ -206,7 +206,7 @@ describe('CodeOwnersProcessor', () => { const read = jest .fn() .mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() })); - const reader = { read, readTree: jest.fn() }; + const reader = { read, readTree: jest.fn(), search: jest.fn() }; const owner = await resolveCodeOwner(mockLocation(), { reader, logger }); expect(owner).toBe('backstage-core'); @@ -216,7 +216,7 @@ describe('CodeOwnersProcessor', () => { const read = jest .fn() .mockImplementation(() => mockReadResult({ error: 'error: foo' })); - const reader = { read, readTree: jest.fn() }; + const reader = { read, readTree: jest.fn(), search: jest.fn() }; await expect( resolveCodeOwner(mockLocation(), { reader, logger }), @@ -230,7 +230,7 @@ describe('CodeOwnersProcessor', () => { const read = jest .fn() .mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() })); - const reader = { read, readTree: jest.fn() }; + const reader = { read, readTree: jest.fn(), search: jest.fn() }; const processor = new CodeOwnersProcessor({ reader, logger }); return { entity, processor, read }; diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts index e2b8a8b62c..e1d7e22ccf 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts @@ -20,7 +20,7 @@ import * as codeowners from 'codeowners-utils'; import { CodeOwnersEntry } from 'codeowners-utils'; // NOTE: This can be removed when ES2021 is implemented import 'core-js/features/promise'; -import parseGitUri from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; import { filter, get, head, pipe, reverse } from 'lodash/fp'; import { Logger } from 'winston'; import { CatalogProcessor } from './types'; @@ -123,7 +123,7 @@ export function buildCodeOwnerUrl( basePath: string, codeOwnersPath: string, ): string { - return buildUrl({ ...parseGitUri(basePath), codeOwnersPath }); + return buildUrl({ ...parseGitUrl(basePath), codeOwnersPath }); } export function parseCodeOwners(ownersText: string) { diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts index 998dfb62a2..476510d757 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts @@ -15,30 +15,73 @@ */ import { LocationSpec } from '@backstage/catalog-model'; -import { toAbsoluteUrl } from './LocationEntityProcessor'; +import { ConfigReader } from '@backstage/config'; +import { + ScmIntegrations, + ScmIntegrationRegistry, +} from '@backstage/integration'; import path from 'path'; +import { toAbsoluteUrl } from './LocationEntityProcessor'; describe('LocationEntityProcessor', () => { describe('toAbsoluteUrl', () => { it('handles files', () => { + const integrations = ({} as unknown) as ScmIntegrationRegistry; const base: LocationSpec = { type: 'file', target: `some${path.sep}path${path.sep}catalog-info.yaml`, }; - expect(toAbsoluteUrl(base, `.${path.sep}c`)).toBe( + expect(toAbsoluteUrl(integrations, base, `.${path.sep}c`)).toBe( `some${path.sep}path${path.sep}c`, ); - expect(toAbsoluteUrl(base, `${path.sep}c`)).toBe(`${path.sep}c`); + expect(toAbsoluteUrl(integrations, base, `${path.sep}c`)).toBe( + `${path.sep}c`, + ); }); it('handles urls', () => { + const integrations = ScmIntegrations.fromConfig(new ConfigReader({})); const base: LocationSpec = { type: 'url', target: 'http://a.com/b/catalog-info.yaml', }; - expect(toAbsoluteUrl(base, './c/d')).toBe('http://a.com/b/c/d'); - expect(toAbsoluteUrl(base, 'c/d')).toBe('http://a.com/b/c/d'); - expect(toAbsoluteUrl(base, 'http://b.com/z')).toBe('http://b.com/z'); + jest.spyOn(integrations, 'resolveUrl'); + + expect(toAbsoluteUrl(integrations, base, './c/d')).toBe( + 'http://a.com/b/c/d', + ); + expect(toAbsoluteUrl(integrations, base, 'c/d')).toBe( + 'http://a.com/b/c/d', + ); + expect(toAbsoluteUrl(integrations, base, 'http://b.com/z')).toBe( + 'http://b.com/z', + ); + + expect(integrations.resolveUrl).toBeCalledTimes(3); + }); + + it('handles azure urls specifically', () => { + const integrations = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + azure: [{ host: 'dev.azure.com' }], + }, + }), + ); + + expect( + toAbsoluteUrl( + integrations, + { + type: 'url', + target: + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml', + }, + './a.yaml', + ), + ).toBe( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', + ); }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts index 1c7e13f1c4..f4c8c82957 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts @@ -15,11 +15,16 @@ */ import { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import path from 'path'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import path from 'path'; -export function toAbsoluteUrl(base: LocationSpec, target: string): string { +export function toAbsoluteUrl( + integrations: ScmIntegrationRegistry, + base: LocationSpec, + target: string, +): string { try { if (base.type === 'file') { if (target.startsWith('.')) { @@ -27,13 +32,19 @@ export function toAbsoluteUrl(base: LocationSpec, target: string): string { } return target; } - return new URL(target, base.target).toString(); + return integrations.resolveUrl({ url: target, base: base.target }); } catch (e) { return target; } } -export class LocationRefProcessor implements CatalogProcessor { +type Options = { + integrations: ScmIntegrationRegistry; +}; + +export class LocationEntityProcessor implements CatalogProcessor { + constructor(private readonly options: Options) {} + async postProcessEntity( entity: Entity, location: LocationSpec, @@ -47,7 +58,7 @@ export class LocationRefProcessor implements CatalogProcessor { emit( result.inputError( location, - `LocationRefProcessor cannot handle ${type} type location with target ${location.target} that ends with a path separator`, + `LocationEntityProcessor cannot handle ${type} type location with target ${location.target} that ends with a path separator`, ), ); } @@ -61,7 +72,11 @@ export class LocationRefProcessor implements CatalogProcessor { } for (const maybeRelativeTarget of targets) { - const target = toAbsoluteUrl(location, maybeRelativeTarget); + const target = toAbsoluteUrl( + this.options.integrations, + location, + maybeRelativeTarget, + ); emit(result.location({ type, target }, false)); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts index 8b2632ad5d..00b6af94e5 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts @@ -27,7 +27,7 @@ import { describe('PlaceholderProcessor', () => { const read: jest.MockedFunction = jest.fn(); - const reader: UrlReader = { read, readTree: jest.fn() }; + const reader: UrlReader = { read, readTree: jest.fn(), search: jest.fn() }; beforeEach(() => { jest.resetAllMocks(); diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts index 9bf72d620b..db731b5b17 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -14,26 +14,37 @@ * limitations under the License. */ -import { UrlReaderProcessor } from './UrlReaderProcessor'; -import { getVoidLogger, UrlReaders } from '@backstage/backend-common'; +import { + getVoidLogger, + UrlReader, + UrlReaders, +} from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; +import { msw } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { msw } from '@backstage/test-utils'; import { CatalogProcessorEntityResult, CatalogProcessorErrorResult, CatalogProcessorResult, } from './types'; +import { UrlReaderProcessor } from './UrlReaderProcessor'; +import { defaultEntityDataParser } from './util/parse'; describe('UrlReaderProcessor', () => { - const mockApiOrigin = 'http://localhost:23000'; - const server = setupServer(); + const mockApiOrigin = 'http://localhost'; + const server = setupServer(); msw.setupDefaultHandlers(server); + it('should load from url', async () => { const logger = getVoidLogger(); - const reader = UrlReaders.default({ logger, config: new ConfigReader({}) }); + const reader = UrlReaders.default({ + logger, + config: new ConfigReader({ + backend: { reading: { allow: [{ host: 'localhost' }] } }, + }), + }); const processor = new UrlReaderProcessor({ reader, logger }); const spec = { type: 'url', @@ -47,17 +58,22 @@ describe('UrlReaderProcessor', () => { ); const generated = (await new Promise(emit => - processor.readLocation(spec, false, emit), + processor.readLocation(spec, false, emit, defaultEntityDataParser), )) as CatalogProcessorEntityResult; expect(generated.type).toBe('entity'); - expect(generated.location).toBe(spec); + expect(generated.location).toEqual(spec); expect(generated.entity).toEqual({ mock: 'entity' }); }); it('should fail load from url with error', async () => { const logger = getVoidLogger(); - const reader = UrlReaders.default({ logger, config: new ConfigReader({}) }); + const reader = UrlReaders.default({ + logger, + config: new ConfigReader({ + backend: { reading: { allow: [{ host: 'localhost' }] } }, + }), + }); const processor = new UrlReaderProcessor({ reader, logger }); const spec = { type: 'url', @@ -71,7 +87,7 @@ describe('UrlReaderProcessor', () => { ); const generated = (await new Promise(emit => - processor.readLocation(spec, false, emit), + processor.readLocation(spec, false, emit, defaultEntityDataParser), )) as CatalogProcessorErrorResult; expect(generated.type).toBe('error'); @@ -81,4 +97,27 @@ describe('UrlReaderProcessor', () => { `Unable to read url, NotFoundError: could not read ${mockApiOrigin}/component-notfound.yaml, 404 Not Found`, ); }); + + it('uses search when there are globs', async () => { + const logger = getVoidLogger(); + + const reader: jest.Mocked = { + read: jest.fn(), + readTree: jest.fn(), + search: jest.fn().mockImplementation(async () => []), + }; + + const processor = new UrlReaderProcessor({ reader, logger }); + + const emit = jest.fn(); + + await processor.readLocation( + { type: 'url', target: 'https://github.com/a/b/blob/x/**/b.yaml' }, + false, + emit, + defaultEntityDataParser, + ); + + expect(reader.search).toBeCalledTimes(1); + }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts index 04f5ee738a..b70ef2877d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts @@ -16,10 +16,15 @@ import { UrlReader } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; +import parseGitUrl from 'git-url-parse'; +import limiterFactory from 'p-limit'; import { Logger } from 'winston'; import * as result from './results'; -import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import { parseEntityYaml } from './util/parse'; +import { + CatalogProcessor, + CatalogProcessorEmit, + CatalogProcessorParser, +} from './types'; // TODO(Rugvip): Added for backwards compatibility when moving to UrlReader, this // can be removed in a bit @@ -43,6 +48,7 @@ export class UrlReaderProcessor implements CatalogProcessor { location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, + parser: CatalogProcessorParser, ): Promise { if (deprecatedTypes.includes(location.type)) { // TODO(Rugvip): Remove this warning a month or two into 2021, and remove support for the deprecated types. @@ -55,10 +61,14 @@ export class UrlReaderProcessor implements CatalogProcessor { } try { - const data = await this.options.reader.read(location.target); - - for (const parseResult of parseEntityYaml(data, location)) { - emit(parseResult); + const output = await this.doRead(location.target); + for (const item of output) { + for await (const parseResult of parser({ + data: item.data, + location: { type: location.type, target: item.url }, + })) { + emit(parseResult); + } } } catch (error) { const message = `Unable to read ${location.type}, ${error}`; @@ -74,4 +84,25 @@ export class UrlReaderProcessor implements CatalogProcessor { return true; } + + private async doRead( + location: string, + ): Promise<{ data: Buffer; url: string }[]> { + // Does it contain globs? I.e. does it contain asterisks or question marks + // (no curly braces for now) + const { filepath } = parseGitUrl(location); + if (filepath?.match(/[*?]/)) { + const limiter = limiterFactory(5); + const response = await this.options.reader.search(location); + const output = response.files.map(async file => ({ + url: file.url, + data: await limiter(file.content), + })); + return Promise.all(output); + } + + // Otherwise do a plain read + const data = await this.options.reader.read(location); + return [{ url: location, data }]; + } } diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index a7b00d7065..05dd327f0e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -23,7 +23,7 @@ export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor'; -export { LocationRefProcessor } from './LocationEntityProcessor'; +export { LocationEntityProcessor } from './LocationEntityProcessor'; export { MicrosoftGraphOrgReaderProcessor } from './MicrosoftGraphOrgReaderProcessor'; export { PlaceholderProcessor } from './PlaceholderProcessor'; export type { PlaceholderResolver } from './PlaceholderProcessor'; diff --git a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts index bdb5918b2f..3dfc58e773 100644 --- a/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/microsoftGraph/client.ts @@ -96,6 +96,10 @@ export class MicrosoftGraphClient { scopes: ['https://graph.microsoft.com/.default'], }); + if (!token) { + throw new Error('Error while requesting token for Microsoft Graph'); + } + return await fetch(url, { headers: { Authorization: `Bearer ${token.accessToken}`, diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index 5da3510f7a..f7e11d5616 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -27,12 +27,15 @@ export type CatalogProcessor = { * @param location The location to read * @param optional Whether a missing target should trigger an error * @param emit A sink for items resulting from the read + * @param parser A parser, that is able to take the raw catalog descriptor + * data and turn it into the actual result pieces. * @returns True if handled by this processor, false otherwise */ readLocation?( location: LocationSpec, optional: boolean, emit: CatalogProcessorEmit, + parser: CatalogProcessorParser, ): Promise; /** @@ -46,12 +49,16 @@ export type CatalogProcessor = { * @param entity The (possibly partial) entity to process * @param location The location that the entity came from * @param emit A sink for auxiliary items resulting from the processing + * @param originLocation The location that the entity originally came from. + * While location resolves to the direct parent location, originLocation + * tells which location was used to start the ingestion loop. * @returns The same entity or a modified version of it */ preProcessEntity?( entity: Entity, location: LocationSpec, emit: CatalogProcessorEmit, + originLocation: LocationSpec, ): Promise; /** @@ -96,6 +103,16 @@ export type CatalogProcessor = { ): Promise; }; +/** + * A parser, that is able to take the raw catalog descriptor data and turn it + * into the actual result pieces. The default implementation performs a YAML + * document parsing. + */ +export type CatalogProcessorParser = (options: { + data: Buffer; + location: LocationSpec; +}) => AsyncIterable; + export type CatalogProcessorEmit = (generated: CatalogProcessorResult) => void; export type CatalogProcessorLocationResult = { diff --git a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts index c3dcd42d62..aa24968d6d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts @@ -18,7 +18,7 @@ import { Entity, LocationSpec } from '@backstage/catalog-model'; import lodash from 'lodash'; import yaml from 'yaml'; import * as result from '../results'; -import { CatalogProcessorResult } from '../types'; +import { CatalogProcessorParser, CatalogProcessorResult } from '../types'; export function* parseEntityYaml( data: Buffer, @@ -50,3 +50,12 @@ export function* parseEntityYaml( } } } + +export const defaultEntityDataParser: CatalogProcessorParser = async function* defaultEntityDataParser({ + data, + location, +}) { + for (const e of parseEntityYaml(data, location)) { + yield e; + } +}; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts index 37337f76f1..bc58338fce 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts @@ -20,6 +20,7 @@ import { ConfigReader } from '@backstage/config'; import Knex from 'knex'; import yaml from 'yaml'; import { DatabaseManager } from '../database'; +import { CatalogProcessorParser } from '../ingestion'; import * as result from '../ingestion/processors/results'; import { CatalogBuilder, CatalogEnvironment } from './CatalogBuilder'; @@ -43,6 +44,7 @@ describe('CatalogBuilder', () => { const reader: jest.Mocked = { read: jest.fn(), readTree: jest.fn(), + search: jest.fn(), }; const env: CatalogEnvironment = { logger: getVoidLogger(), @@ -209,4 +211,26 @@ describe('CatalogBuilder', () => { }), ]); }); + + it('setEntityDataParser works', async () => { + const mockParser: CatalogProcessorParser = jest + .fn() + .mockImplementation(() => {}); + + const builder = new CatalogBuilder(env) + .setEntityDataParser(mockParser) + .replaceProcessors([ + { + async readLocation(_location, _optional, _emit, parser) { + expect(parser).toBe(mockParser); + return true; + }, + }, + ]); + + const { higherOrderOperation } = await builder.build(); + await higherOrderOperation.addLocation({ type: 'x', target: 'y' }); + + expect.assertions(1); + }); }); diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index f6170135fd..f7544ef42e 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -26,6 +26,7 @@ import { Validators, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; import lodash from 'lodash'; import { Logger } from 'winston'; import { @@ -39,14 +40,15 @@ import { AnnotateLocationEntityProcessor, BuiltinKindsEntityProcessor, CatalogProcessor, + CatalogProcessorParser, CodeOwnersProcessor, FileReaderProcessor, GithubOrgReaderProcessor, HigherOrderOperation, HigherOrderOperations, LdapOrgReaderProcessor, + LocationEntityProcessor, LocationReaders, - LocationRefProcessor, MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, @@ -60,6 +62,7 @@ import { textPlaceholderResolver, yamlPlaceholderResolver, } from '../ingestion/processors/PlaceholderProcessor'; +import { defaultEntityDataParser } from '../ingestion/processors/util/parse'; import { LocationAnalyzer } from '../ingestion/types'; export type CatalogEnvironment = { @@ -96,6 +99,7 @@ export class CatalogBuilder { private fieldFormatValidators: Partial; private processors: CatalogProcessor[]; private processorsReplace: boolean; + private parser: CatalogProcessorParser | undefined; constructor(env: CatalogEnvironment) { this.env = env; @@ -105,6 +109,7 @@ export class CatalogBuilder { this.fieldFormatValidators = {}; this.processors = []; this.processorsReplace = false; + this.parser = undefined; } /** @@ -197,6 +202,20 @@ export class CatalogBuilder { return this; } + /** + * Sets up the catalog to use a custom parser for entity data. + * + * This is the function that gets called immediately after some raw entity + * specification data has been read from a remote source, and needs to be + * parsed and emitted as structured data. + * + * @param parser The custom parser + */ + setEntityDataParser(parser: CatalogProcessorParser): CatalogBuilder { + this.parser = parser; + return this; + } + /** * Wires up and returns all of the component parts of the catalog */ @@ -211,9 +230,11 @@ export class CatalogBuilder { const policy = this.buildEntityPolicy(); const processors = this.buildProcessors(); const rulesEnforcer = CatalogRulesEnforcer.fromConfig(config); + const parser = this.parser || defaultEntityDataParser; const locationReader = new LocationReaders({ ...this.env, + parser, processors, rulesEnforcer, policy, @@ -260,6 +281,7 @@ export class CatalogBuilder { private buildProcessors(): CatalogProcessor[] { const { config, logger, reader } = this.env; + const integrations = ScmIntegrations.fromConfig(config); this.checkDeprecatedReaderProcessors(); @@ -286,7 +308,7 @@ export class CatalogBuilder { MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }), new UrlReaderProcessor({ reader, logger }), new CodeOwnersProcessor({ reader, logger }), - new LocationRefProcessor(), + new LocationEntityProcessor({ integrations }), new AnnotateLocationEntityProcessor(), ); } diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index 43c2967233..17cfc77d8b 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { errorHandler } from '@backstage/backend-common'; +import { errorHandler, NotFoundError } from '@backstage/backend-common'; import { locationSpecSchema, analyzeLocationSchema, @@ -57,7 +57,7 @@ export async function createRouter( const filter = EntityFilters.ofQuery(req.query); const fieldMapper = translateQueryToFieldMapper(req.query); const entities = await entitiesCatalog.entities(filter); - res.status(200).send(entities.map(fieldMapper)); + res.status(200).json(entities.map(fieldMapper)); }) .post('/entities', async (req, res) => { const body = await requireRequestBody(req); @@ -67,7 +67,7 @@ export async function createRouter( const [entity] = await entitiesCatalog.entities( EntityFilters.ofMatchers({ 'metadata.uid': result.entityId }), ); - res.status(200).send(entity); + res.status(200).json(entity); }) .get('/entities/by-uid/:uid', async (req, res) => { const { uid } = req.params; @@ -75,14 +75,14 @@ export async function createRouter( EntityFilters.ofMatchers({ 'metadata.uid': uid }), ); if (!entities.length) { - res.status(404).send(`No entity with uid ${uid}`); + throw new NotFoundError(`No entity with uid ${uid}`); } - res.status(200).send(entities[0]); + res.status(200).json(entities[0]); }) .delete('/entities/by-uid/:uid', async (req, res) => { const { uid } = req.params; await entitiesCatalog.removeEntityByUid(uid); - res.status(204).send(); + res.status(204).end(); }) .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { const { kind, namespace, name } = req.params; @@ -94,13 +94,11 @@ export async function createRouter( }), ); if (!entities.length) { - res - .status(404) - .send( - `No entity with kind ${kind} namespace ${namespace} name ${name}`, - ); + throw new NotFoundError( + `No entity with kind ${kind} namespace ${namespace} name ${name}`, + ); } - res.status(200).send(entities[0]); + res.status(200).json(entities[0]); }); } @@ -109,7 +107,7 @@ export async function createRouter( const input = await validateRequestBody(req, locationSpecSchema); const dryRun = yn(req.query.dryRun, { default: false }); const output = await higherOrderOperation.addLocation(input, { dryRun }); - res.status(201).send(output); + res.status(201).json(output); }); } @@ -117,22 +115,22 @@ export async function createRouter( router .get('/locations', async (_req, res) => { const output = await locationsCatalog.locations(); - res.status(200).send(output); + res.status(200).json(output); }) .get('/locations/:id/history', async (req, res) => { const { id } = req.params; const output = await locationsCatalog.locationHistory(id); - res.status(200).send(output); + res.status(200).json(output); }) .get('/locations/:id', async (req, res) => { const { id } = req.params; const output = await locationsCatalog.location(id); - res.status(200).send(output); + res.status(200).json(output); }) .delete('/locations/:id', async (req, res) => { const { id } = req.params; await locationsCatalog.removeLocation(id); - res.status(204).send(); + res.status(204).end(); }); } @@ -140,7 +138,7 @@ export async function createRouter( router.post('/analyze-location', async (req, res) => { const input = await validateRequestBody(req, analyzeLocationSchema); const output = await locationAnalyzer.analyzeLocation(input); - res.status(200).send(output); + res.status(200).json(output); }); } diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index ceee335db0..ad536e45dc 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/plugin-catalog-graphql +## 0.2.6 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + ## 0.2.5 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index fb013f53c3..9d76545909 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graphql", - "version": "0.2.5", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", "@graphql-modules/core": "^0.7.17", "apollo-server": "^2.16.1", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.6.0", "@backstage/test-utils": "^0.1.5", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index a7301e43a3..0311d865a9 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,100 @@ # @backstage/plugin-catalog-import +## 0.3.7 + +### Patch Changes + +- ceef4dd89: Export _api_ (Client, API, ref) from the catalog import plugin. +- b712841d6: Migrated to new composability API, exporting the plugin instance as `catalogImportPlugin`, and the page as `CatalogImportPage`. +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [c4abcdb60] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [064c513e1] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [3149bfe63] +- Updated dependencies [54c7d02f7] +- Updated dependencies [2e62aea6f] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/integration@0.3.2 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.3.6 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/integration@0.3.1 + - @backstage/plugin-catalog@0.2.14 + +## 0.3.5 + +### Patch Changes + +- 2b514d532: Modifying import functionality to register existing catalog-info.yaml if one exists in given GitHub repository +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [0b135e7e0] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [fa8ba330a] +- Updated dependencies [a187b8ad0] +- Updated dependencies [ed6baab66] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/integration@0.3.0 + - @backstage/plugin-catalog@0.2.12 + +## 0.3.4 + +### Patch Changes + +- 34a01a171: Improve how URLs are analyzed for add/import +- bc40ccecf: Add more generic descriptions for the catalog-import form. +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- be5ac7fde: Remove dependency to `@backstage/plugin-catalog-backend`. +- Updated dependencies [466354aaa] +- Updated dependencies [f3b064e1c] +- Updated dependencies [c00488983] +- Updated dependencies [265a7ab30] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/integration@0.2.0 + - @backstage/catalog-model@0.6.1 + - @backstage/plugin-catalog@0.2.11 + - @backstage/core@0.4.4 + +## 0.3.3 + +### Patch Changes + +- edb7d0775: Create "url" type location when registering using the catalog-import plugin. +- Updated dependencies [99be3057c] +- Updated dependencies [49d2016a4] +- Updated dependencies [9c09a364f] +- Updated dependencies [73e75ea0a] +- Updated dependencies [036a84373] +- Updated dependencies [071711d70] + - @backstage/plugin-catalog-backend@0.5.2 + - @backstage/plugin-catalog@0.2.10 + - @backstage/integration@0.1.5 + ## 0.3.2 ### Patch Changes diff --git a/plugins/catalog-import/dev/index.tsx b/plugins/catalog-import/dev/index.tsx index 812a5585d4..c9b46a7ed4 100644 --- a/plugins/catalog-import/dev/index.tsx +++ b/plugins/catalog-import/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { catalogImportPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(catalogImportPlugin).render(); diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index a51001d585..2b2b016716 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.3.2", + "version": "0.3.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,17 +30,16 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/plugin-catalog-backend": "^0.5.0", - "@backstage/integration": "^0.1.4", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/integration": "^0.3.2", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@octokit/rest": "^18.0.6", - "git-url-parse": "^11.4.3", + "@octokit/rest": "^18.0.12", + "git-url-parse": "^11.4.4", "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^6.6.0", @@ -50,8 +49,9 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/catalog-client": "^0.3.5", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 99526a3eaa..5abb0e3e53 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -30,6 +30,11 @@ export interface CatalogImportApi { fileContent: string; githubIntegrationConfig: GitHubIntegrationConfig; }): Promise<{ link: string; location: string }>; + checkForExistingCatalogInfo(options: { + owner: string; + repo: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }): Promise<{ exists: boolean; url?: string }>; createRepositoryLocation(options: { location: string }): Promise; generateEntityDefinitions(options: { repo: string; diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts new file mode 100644 index 0000000000..0e0cf4b323 --- /dev/null +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CatalogImportClient } from './CatalogImportClient'; + +jest.mock('@octokit/rest', () => ({ + Octokit: jest.fn().mockImplementation(() => { + return { + repos: { + get: () => + Promise.resolve({ + data: { + default_branch: 'main', + }, + }), + }, + search: { + code: () => + Promise.resolve({ + data: { + total_count: 2, + items: [ + { path: 'simple/path/catalog-info.yaml' }, + { path: 'co/mple/x/path/catalog-info.yaml' }, + { path: 'catalog-info.yaml' }, + ], + }, + }), + }, + }; + }), +})); + +describe('CatalogImportClient', () => { + describe('checkForExistingCatalogInfo', () => { + const cic = new CatalogImportClient({ + discoveryApi: { getBaseUrl: () => Promise.resolve('base') }, + githubAuthApi: { getAccessToken: (_, __) => Promise.resolve('token') }, + configApi: {} as any, + }); + it('should return the closest-to-root catalog-info from multiple responses', async () => { + const respo = await cic.checkForExistingCatalogInfo({ + owner: 'test-user', + repo: 'rest-repo', + githubIntegrationConfig: { host: 'https://github.com' }, + }); + expect(respo.exists).toBe(true); + expect(respo.url).toBe('blob/main/catalog-info.yaml'); + }); + }); +}); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index 0e3ea2e1d6..d1509c2105 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -17,7 +17,6 @@ import { Octokit } from '@octokit/rest'; import { DiscoveryApi, OAuthApi, ConfigApi } from '@backstage/core'; import { CatalogImportApi } from './CatalogImportApi'; -import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend'; import { PartialEntity } from '../util/types'; import { GitHubIntegrationConfig } from '@backstage/integration'; @@ -49,7 +48,7 @@ export class CatalogImportClient implements CatalogImportApi { }, method: 'POST', body: JSON.stringify({ - location: { type: 'github', target: repo }, + location: { type: 'url', target: repo }, }), }, ).catch(e => { @@ -61,8 +60,8 @@ export class CatalogImportClient implements CatalogImportApi { ); } - const payload = (await response.json()) as AnalyzeLocationResponse; - return payload.generateEntities.map(x => x.entity); + const payload = await response.json(); + return payload.generateEntities.map((x: any) => x.entity); } async createRepositoryLocation({ @@ -78,7 +77,7 @@ export class CatalogImportClient implements CatalogImportApi { }, method: 'POST', body: JSON.stringify({ - type: 'github', + type: 'url', target: location, presence: 'optional', }), @@ -91,6 +90,52 @@ export class CatalogImportClient implements CatalogImportApi { } } + async checkForExistingCatalogInfo({ + owner, + repo, + githubIntegrationConfig, + }: { + owner: string; + repo: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }): Promise<{ exists: boolean; url?: string }> { + const token = await this.githubAuthApi.getAccessToken(['repo']); + const octo = new Octokit({ + auth: token, + baseUrl: githubIntegrationConfig.apiBaseUrl, + }); + const catalogFileName = 'catalog-info.yaml'; + const query = `repo:${owner}/${repo}+filename:${catalogFileName}`; + + const searchResult = await octo.search.code({ q: query }).catch(e => { + throw new Error( + formatHttpErrorMessage( + "Couldn't search repository for metadata file.", + e, + ), + ); + }); + const exists = searchResult.data.total_count > 0; + if (exists) { + const repoInformation = await octo.repos.get({ owner, repo }).catch(e => { + throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e)); + }); + const defaultBranch = repoInformation.data.default_branch; + + // Github search sorts returned values with 'best match' using 'multiple factors to boost the most relevant item', + // aka magic. + // Sorting to use the shortest item, closest to the repository root. + const catalogInfoItem = searchResult.data.items + .map(it => it.path) + .sort((a, b) => a.length - b.length)[0]; + return { + url: `blob/${defaultBranch}/${catalogInfoItem}`, + exists, + }; + } + return { exists }; + } + async submitPrToRepo({ owner, repo, diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx index ab5c71c7d3..9fb170c85c 100644 --- a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx +++ b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx @@ -14,32 +14,35 @@ * limitations under the License. */ -import React, { useCallback, useState } from 'react'; -import { - Button, - CircularProgress, - Grid, - Link, - List, - ListItem, - Typography, - Divider, -} from '@material-ui/core'; -import { useGithubRepos } from '../util/useGithubRepos'; -import { ConfigSpec } from './ImportComponentPage'; +import { Entity } from '@backstage/catalog-model'; import { errorApiRef, RouteRef, StructuredMetadataTable, useApi, } from '@backstage/core'; -import parseGitUri from 'git-url-parse'; -import { PartialEntity } from '../util/types'; +import { + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog-react'; +import { + Button, + CircularProgress, + Divider, + Grid, + Link, + List, + ListItem, + Typography, +} from '@material-ui/core'; +import React, { useCallback, useState } from 'react'; import { generatePath, resolvePath } from 'react-router'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; import { Link as RouterLink } from 'react-router-dom'; import * as YAML from 'yaml'; +import { PartialEntity } from '../util/types'; +import { urlType } from '../util/urls'; +import { useGithubRepos } from '../util/useGithubRepos'; +import { ConfigSpec } from './ImportComponentPage'; const getEntityCatalogPath = ({ entity, @@ -81,7 +84,7 @@ const ComponentConfigDisplay = ({ const onNext = useCallback(async () => { try { setSubmitting(true); - if (!parseGitUri(configFile.location).filepathtype) { + if (urlType(configFile.location) === 'tree') { const result = await submitPrToRepo(configFile); savePRLink(result.link); setSubmitting(false); @@ -100,7 +103,7 @@ const ComponentConfigDisplay = ({ return ( - {!parseGitUri(configFile.location).filepathtype ? ( + {urlType(configFile.location) === 'tree' ? ( Following config object will be submitted in a pull request to the repository{' '} @@ -127,7 +130,7 @@ const ComponentConfigDisplay = ({ )} - {!parseGitUri(configFile.location).filepathtype ? ( + {urlType(configFile.location) === 'tree' ? (
{YAML.stringify(configFile.config)}
) : ( diff --git a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx new file mode 100644 index 0000000000..38b1065325 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CatalogClient } from '@backstage/catalog-client'; +import { + ApiProvider, + ApiRegistry, + DiscoveryApi, + errorApiRef, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { catalogImportApiRef, CatalogImportClient } from '../api'; +import { RegisterComponentForm } from './ImportComponentForm'; + +describe('', () => { + let apis: ApiRegistry; + + const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), + }; + + beforeEach(() => { + apis = ApiRegistry.from([ + [catalogApiRef, new CatalogClient({ discoveryApi: {} as DiscoveryApi })], + [ + catalogImportApiRef, + new CatalogImportClient({ + discoveryApi: { getBaseUrl: () => Promise.resolve('base') }, + githubAuthApi: { + getAccessToken: (_, __) => Promise.resolve('token'), + }, + configApi: {} as any, + }), + ], + [errorApiRef, mockErrorApi], + ]); + }); + + async function renderSUT( + nextStep: () => void = () => {}, + saveConfig: () => void = () => {}, + ) { + return await renderInTestApp( + + + , + ); + } + + it('Renders without exploding', async () => { + await renderSUT(); + expect( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + ).toBeInTheDocument(); + }); + + it('Should have basic URL validation for input', async () => { + await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { target: { value: 'not a url' } }, + ); + }); + await waitFor(() => { + fireEvent.click(screen.getByText('Next')); + }); + expect(screen.getByText('Must start with https://.')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx index dab5ccbea2..b3ec050ea3 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.tsx @@ -15,6 +15,7 @@ */ import { errorApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { BackstageTheme } from '@backstage/theme'; import { Button, @@ -26,11 +27,10 @@ import { makeStyles } from '@material-ui/core/styles'; import React from 'react'; import { useForm } from 'react-hook-form'; import { useMountedState } from 'react-use'; -import parseGitUri from 'git-url-parse'; -import { ComponentIdValidators } from '../util/validate'; +import { urlType } from '../util/urls'; import { useGithubRepos } from '../util/useGithubRepos'; +import { ComponentIdValidators } from '../util/validate'; import { ConfigSpec } from './ImportComponentPage'; -import { catalogApiRef } from '@backstage/plugin-catalog'; const useStyles = makeStyles(theme => ({ form: { @@ -46,9 +46,14 @@ const useStyles = makeStyles(theme => ({ type Props = { nextStep: () => void; saveConfig: (configFile: ConfigSpec) => void; + repository: string; }; -export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { +export const RegisterComponentForm = ({ + nextStep, + saveConfig, + repository, +}: Props) => { const { register, handleSubmit, errors, formState } = useForm({ mode: 'onChange', }); @@ -59,27 +64,45 @@ export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { const isMounted = useMountedState(); const errorApi = useApi(errorApiRef); - const { generateEntityDefinitions } = useGithubRepos(); + const { + generateEntityDefinitions, + checkForExistingCatalogInfo, + } = useGithubRepos(); const onSubmit = async (formData: Record) => { const { componentLocation: target } = formData; - try { - if (!isMounted()) return; - const type = !parseGitUri(target).filepathtype ? 'repo' : 'file'; + async function saveCatalogFileConfig(targetString: string) { + const data = await catalogApi.addLocation({ target: targetString }); + saveConfig({ + type: 'file', + location: data.location.target, + config: data.entities, + }); + } - if (type === 'repo') { + async function trySaveRepositoryConfig(targetString: string) { + const existingCatalog = await checkForExistingCatalogInfo(targetString); + if (existingCatalog.exists) { + const targetUrl = targetString.endsWith('/') + ? `${targetString}${existingCatalog.url}` + : `${targetString}/${existingCatalog.url}`; + await saveCatalogFileConfig(targetUrl); + } else { saveConfig({ - type, + type: 'tree', location: target, config: await generateEntityDefinitions(target), }); + } + } + + try { + if (!isMounted()) return; + const type = urlType(target); + if (type === 'tree') { + await trySaveRepositoryConfig(target); } else { - const data = await catalogApi.addLocation({ target }); - saveConfig({ - type, - location: data.location.target, - config: data.entities, - }); + await saveCatalogFileConfig(target); } nextStep(); } catch (e) { @@ -103,7 +126,7 @@ export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { name="componentLocation" required margin="normal" - helperText="Enter the full path to the repository in GitHub to start tracking your component." + helperText={`Enter the full path to the repository in ${repository} to start tracking your component.`} inputRef={register({ required: true, validate: ComponentIdValidators, diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx new file mode 100644 index 0000000000..f6ef07e1b2 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx @@ -0,0 +1,266 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CatalogClient } from '@backstage/catalog-client'; +import { + ApiProvider, + ApiRegistry, + configApiRef, + errorApiRef, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { msw, renderInTestApp } from '@backstage/test-utils'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import React from 'react'; +import { catalogImportApiRef, CatalogImportClient } from '../api'; +import { ImportComponentPage } from './ImportComponentPage'; + +let codeSearchMockResponse: () => Promise<{ + data: { + total_count: number; + items: Array<{ path: string }>; + }; +}>; + +let findGithubConfigMockResponse = () => ({ + host: 'test.localhost', + owner: 'someuser', +}); + +jest.mock('@backstage/integration', () => ({ + readGitHubIntegrationConfigs: () => ({ + find: findGithubConfigMockResponse, + }), +})); + +jest.mock('@octokit/rest', () => ({ + Octokit: jest.fn().mockImplementation(() => { + return { + repos: { + get: () => + Promise.resolve({ + data: { + default_branch: 'main', + }, + }), + }, + search: { + code: codeSearchMockResponse, + }, + }; + }), +})); + +const OUR_GITHUB_TEST_REPO = 'https://github.com/someuser/somerepo'; +describe('', () => { + const server = setupServer(); + msw.setupDefaultHandlers(server); + + beforeEach(() => { + server.use( + rest.post('https://backend.localhost/locations', (_, res, ctx) => { + return res( + ctx.status(201), + ctx.json(require('../mocks/locations-POST-response.json')), + ); + }), + rest.post('https://backend.localhost/analyze-location', (_, res, ctx) => { + return res( + ctx.json(require('../mocks/analyze-location-POST-response.json')), + ); + }), + ); + }); + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + let apis: ApiRegistry; + + const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), + }; + + beforeEach(() => { + const getBaseUrl = () => Promise.resolve('https://backend.localhost'); + apis = ApiRegistry.from([ + [ + catalogApiRef, + new CatalogClient({ + discoveryApi: { getBaseUrl }, + }), + ], + [ + catalogImportApiRef, + new CatalogImportClient({ + discoveryApi: { getBaseUrl }, + githubAuthApi: { + getAccessToken: (_, __) => Promise.resolve('token'), + }, + configApi: {} as any, + }), + ], + [ + configApiRef, + { + getOptional: () => 'Title', + getOptionalConfigArray: () => [], + has: () => true, + getConfig: () => ({ + has: () => true, + }), + }, + ], + [errorApiRef, mockErrorApi], + ]); + }); + + async function renderSUT() { + return await renderInTestApp( + + + , + ); + } + + it('Should not explode on non-Github URLs', async () => { + findGithubConfigMockResponse = () => undefined!!; + await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { + target: { + value: 'https://test-git-provider.localhost/someuser/somerepo', + }, + }, + ); + }); + + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + const firstStepInput = screen.queryByPlaceholderText( + 'https://github.com/backstage/backstage', + ); + expect(firstStepInput).not.toBeInTheDocument(); + }); + }); + + it('Should offer direct file import from non-Github URLs', async () => { + findGithubConfigMockResponse = () => undefined!!; + await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { + target: { + value: + 'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml', + }, + }, + ); + }); + + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + const firstStepInput = screen.queryByPlaceholderText( + 'https://github.com/backstage/backstage', + ); + expect(firstStepInput).not.toBeInTheDocument(); + }); + expect( + screen.getByText( + 'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml', + ), + ).toBeInTheDocument(); + }); + + it('Should use found yaml file directly and not create a pull request if GitHub api returns one', async () => { + findGithubConfigMockResponse = () => ({ + host: 'test.localhost', + owner: 'someuser', + }); + codeSearchMockResponse = () => + Promise.resolve({ + data: { + total_count: 3, + items: [ + { path: 'simple/path/catalog-info.yaml' }, + { path: 'co/mple/x/path/catalog-info.yaml' }, + { path: 'catalog-info.yaml' }, + ], + }, + }); + await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { target: { value: OUR_GITHUB_TEST_REPO } }, + ); + }); + + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect( + screen.getByText( + 'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml', + ), + ).toBeInTheDocument(); + + const pullReqText = screen.queryByText('pull request'); + expect(pullReqText).not.toBeInTheDocument(); + }); + }); + + it('Should indicate a pull request creation when no yaml file found in the repo', async () => { + findGithubConfigMockResponse = () => ({ + host: 'test.localhost', + owner: 'someuser', + }); + codeSearchMockResponse = () => + Promise.resolve({ + data: { + total_count: 0, + items: [], + }, + }); + const { container } = await renderSUT(); + await waitFor(() => { + fireEvent.input( + screen.getByPlaceholderText('https://github.com/backstage/backstage'), + { target: { value: OUR_GITHUB_TEST_REPO } }, + ); + }); + + fireEvent.click(screen.getByText('Next')); + await waitFor(() => { + expect(screen.getByText(OUR_GITHUB_TEST_REPO)).toBeInTheDocument(); + }); + const textNode = container + .querySelector('a[href="https://github.com/someuser/somerepo"]') + ?.closest('p'); + expect(textNode?.innerHTML).toContain( + 'Following config object will be submitted in a pull request to the repository', + ); + expect( + screen.queryByText( + 'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml', + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx index 9fdb465dd1..adae137a50 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx @@ -24,6 +24,9 @@ import { SupportButton, ContentHeader, RouteRef, + useApi, + configApiRef, + ConfigApi, } from '@backstage/core'; import { RegisterComponentForm } from './ImportComponentForm'; import ImportStepper from './ImportStepper'; @@ -32,11 +35,33 @@ import { ImportFinished } from './ImportFinished'; import { PartialEntity } from '../util/types'; export type ConfigSpec = { - type: 'repo' | 'file'; + type: 'tree' | 'file'; location: string; config: PartialEntity[]; }; +function manifestGenerationAvailable(configApi: ConfigApi): boolean { + return configApi.has('integrations.github'); +} + +function repositories(configApi: ConfigApi): string[] { + const integrations = configApi.getConfig('integrations'); + const repos = []; + if (integrations.has('github')) { + repos.push('GitHub'); + } + if (integrations.has('bitbucket')) { + repos.push('Bitbucket'); + } + if (integrations.has('gitlab')) { + repos.push('GitLab'); + } + if (integrations.has('azure')) { + repos.push('Azure'); + } + return repos; +} + export const ImportComponentPage = ({ catalogRouteRef, }: { @@ -44,7 +69,7 @@ export const ImportComponentPage = ({ }) => { const [activeStep, setActiveStep] = useState(0); const [configFile, setConfigFile] = useState({ - type: 'repo', + type: 'tree', location: '', config: [], }); @@ -53,13 +78,17 @@ export const ImportComponentPage = ({ setActiveStep(step => (options?.reset ? 0 : step + 1)); }; + const configApi = useApi(configApiRef); + const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const repos = repositories(configApi); + const repositoryString = repos.join(', ').replace(/, (\w*)$/, ' or $1'); return (
- + - Start tracking your component in Backstage by adding it to the + Start tracking your component in {appTitle} by adding it to the software catalog. @@ -73,21 +102,29 @@ export const ImportComponentPage = ({ }} > - There are two ways to register an existing component. - - GitHub Repo - - If you already have code in a GitHub repository, enter the full - URL to your repo and a new pull request with a sample Backstage - metadata Entity File (catalog-info.yaml) will be - opened for you. + Ways to register an existing component + + {manifestGenerationAvailable(configApi) && ( + + GitHub Repo + + If you already have code in a GitHub repository without + Backstage metadata file set up for it, enter the full URL to + your repo and a new pull request with a sample Backstage + metadata Entity File (catalog-info.yaml) will + be opened for you. + + + )} - GitHub Repo & Entity File + {repos.length === 1 ? `${repos[0]} ` : ''} Repository & + Entity File - If you've already created a Backstage metadata file and put it - in your repo, you can enter the full URL to that Entity File. + If you've already created a {appTitle} metadata file and put it + in your {repositoryString} repository, you can enter the full + URL to that Entity File. @@ -96,11 +133,14 @@ export const ImportComponentPage = ({ ), }, diff --git a/plugins/catalog-import/src/components/ImportFinished.tsx b/plugins/catalog-import/src/components/ImportFinished.tsx index caba678999..9b0f8575a6 100644 --- a/plugins/catalog-import/src/components/ImportFinished.tsx +++ b/plugins/catalog-import/src/components/ImportFinished.tsx @@ -19,7 +19,7 @@ import { Alert } from '@material-ui/lab'; import { Button, Grid, Link } from '@material-ui/core'; type Props = { - type: 'repo' | 'file'; + type: 'tree' | 'file'; nextStep: (options?: { reset: boolean }) => void; PRLink: string; }; @@ -29,13 +29,13 @@ export const ImportFinished = ({ nextStep, PRLink, type }: Props) => { - {type === 'repo' + {type === 'tree' ? 'Pull requests have been successfully opened. You can start again to import more repositories' : 'Entity added to catalog successfully'} - {type === 'repo' ? ( + {type === 'tree' ? ( { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(catalogImportPlugin).toBeDefined(); }); }); diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts index 829be22045..42210d0ed1 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -21,6 +21,7 @@ import { discoveryApiRef, githubAuthApiRef, configApiRef, + createRoutableExtension, } from '@backstage/core'; import { catalogImportApiRef } from './api/CatalogImportApi'; import { CatalogImportClient } from './api/CatalogImportClient'; @@ -30,7 +31,7 @@ export const rootRouteRef = createRouteRef({ title: 'catalog-import', }); -export const plugin = createPlugin({ +export const catalogImportPlugin = createPlugin({ id: 'catalog-import', apis: [ createApiFactory({ @@ -44,4 +45,14 @@ export const plugin = createPlugin({ new CatalogImportClient({ discoveryApi, githubAuthApi, configApi }), }), ], + routes: { + importPage: rootRouteRef, + }, }); + +export const CatalogImportPage = catalogImportPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/catalog-import/src/setupTests.ts b/plugins/catalog-import/src/setupTests.ts index 825bcd4115..aea2220869 100644 --- a/plugins/catalog-import/src/setupTests.ts +++ b/plugins/catalog-import/src/setupTests.ts @@ -15,3 +15,4 @@ */ import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/catalog-import/src/util/urls.ts b/plugins/catalog-import/src/util/urls.ts new file mode 100644 index 0000000000..c51dc0a999 --- /dev/null +++ b/plugins/catalog-import/src/util/urls.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import parseGitUrl from 'git-url-parse'; + +export type UrlType = 'file' | 'tree'; + +export function urlType(url: string): UrlType { + const { filepathtype, filepath } = parseGitUrl(url); + + if (filepathtype === 'tree' || filepathtype === 'file') { + return filepathtype; + } else if (filepath?.match(/\.ya?ml$/)) { + return 'file'; + } + + return 'tree'; +} diff --git a/plugins/catalog-import/src/util/useGithubRepos.ts b/plugins/catalog-import/src/util/useGithubRepos.ts index 30e77e18a8..a2d299b9f4 100644 --- a/plugins/catalog-import/src/util/useGithubRepos.ts +++ b/plugins/catalog-import/src/util/useGithubRepos.ts @@ -18,22 +18,25 @@ import * as YAML from 'yaml'; import { useApi, configApiRef } from '@backstage/core'; import { catalogImportApiRef } from '../api/CatalogImportApi'; import { ConfigSpec } from '../components/ImportComponentPage'; -import parseGitUri from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; // TODO: (O5ten) Refactor into a core API instead of direct usage like this // https://github.com/backstage/backstage/pull/3613#issuecomment-7408929430 -import { readGitHubIntegrationConfigs } from '@backstage/integration'; +import { + GitHubIntegrationConfig, + readGitHubIntegrationConfigs, +} from '@backstage/integration'; export function useGithubRepos() { const api = useApi(catalogImportApiRef); const config = useApi(configApiRef); - const submitPrToRepo = async (selectedRepo: ConfigSpec) => { + const getGithubIntegrationConfig = (location: string) => { const { name: repoName, owner: ownerName, resource: hostname, - } = parseGitUri(selectedRepo.location); + } = parseGitUrl(location); const configs = readGitHubIntegrationConfigs( config.getOptionalConfigArray('integrations.github') ?? [], @@ -41,9 +44,22 @@ export function useGithubRepos() { const githubIntegrationConfig = configs.find(v => v.host === hostname); if (!githubIntegrationConfig) { throw new Error( - `Unable to locate github-integration for repo-location: ${selectedRepo.location}`, + `Unable to locate github-integration for repo-location, ${location}`, ); } + return { + repoName, + ownerName, + githubIntegrationConfig, + }; + }; + + const submitPrToRepo = async (selectedRepo: ConfigSpec) => { + const { + repoName, + ownerName, + githubIntegrationConfig, + } = getGithubIntegrationConfig(selectedRepo.location); const submitPRResponse = await api .submitPrToRepo({ owner: ownerName, @@ -54,7 +70,7 @@ export function useGithubRepos() { githubIntegrationConfig, }) .catch(e => { - throw new Error(`Failed to submit PR to repo:\n${e.message}`); + throw new Error(`Failed to submit PR to repo, ${e.message}`); }); await api @@ -62,14 +78,41 @@ export function useGithubRepos() { location: submitPRResponse.location, }) .catch(e => { - throw new Error(`Failed to create repository location:\n${e.message}`); + throw new Error(`Failed to create repository location, ${e.message}`); }); return submitPRResponse; }; + const checkForExistingCatalogInfo = async ( + location: string, + ): Promise<{ exists: boolean; url?: string }> => { + let githubConfig: { + repoName: string; + ownerName: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }; + try { + githubConfig = getGithubIntegrationConfig(location); + } catch (e) { + return { exists: false }; + } + return await api + .checkForExistingCatalogInfo({ + owner: githubConfig.ownerName, + repo: githubConfig.repoName, + githubIntegrationConfig: githubConfig.githubIntegrationConfig, + }) + .catch(e => { + throw new Error( + `Failed to inspect repository for existing catalog-info.yaml, ${e.message}`, + ); + }); + }; + return { submitPrToRepo, + checkForExistingCatalogInfo, generateEntityDefinitions: (repo: string) => api.generateEntityDefinitions({ repo }), addLocation: (location: string) => diff --git a/plugins/catalog-react/.eslintrc.js b/plugins/catalog-react/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/catalog-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/catalog-react/CHANGELOG.md b/plugins/catalog-react/CHANGELOG.md new file mode 100644 index 0000000000..90b7c80caf --- /dev/null +++ b/plugins/catalog-react/CHANGELOG.md @@ -0,0 +1,26 @@ +# @backstage/plugin-catalog-react + +## 0.0.2 + +### Patch Changes + +- 7fc89bae2: Display owner and system as entity page links in the tables of the `api-docs` + plugin. + + Move `isOwnerOf` and `getEntityRelations` from `@backstage/plugin-catalog` to + `@backstage/plugin-catalog-react` and export it from there to use it by other + plugins. + +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/catalog-model@0.7.1 diff --git a/plugins/catalog-react/README.md b/plugins/catalog-react/README.md new file mode 100644 index 0000000000..65ab45b9b2 --- /dev/null +++ b/plugins/catalog-react/README.md @@ -0,0 +1,14 @@ +# Catalog React + +WORK IN PROGRESS + +This is shared code of the frontend part of the default catalog plugin. + +It will implement the core API for handling your catalog of software, and +supplies components that can be reused by third-party plugins. + +## Links + +- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog) +- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json new file mode 100644 index 0000000000..64307f6846 --- /dev/null +++ b/plugins/catalog-react/package.json @@ -0,0 +1,58 @@ +{ + "name": "@backstage/plugin-catalog-react", + "version": "0.0.2", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/plugin-catalog-common-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-client": "^0.3.5", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@material-ui/core": "^4.11.0", + "@types/react": "^16.9", + "react": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.6", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/react-hooks": "^3.3.0", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2", + "react-test-renderer": "^16.13.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/catalog-react/src/api.ts b/plugins/catalog-react/src/api.ts new file mode 100644 index 0000000000..9379fa7fb3 --- /dev/null +++ b/plugins/catalog-react/src/api.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 { CatalogApi } from '@backstage/catalog-client'; +import { createApiRef } from '@backstage/core'; + +export const catalogApiRef = createApiRef({ + id: 'plugin.catalog.service', + description: + 'Used by the Catalog plugin to make requests to accompanying backend', +}); diff --git a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx b/plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx similarity index 94% rename from plugins/catalog/src/components/EntityProvider/EntityProvider.tsx rename to plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx index 9135b54638..45cdd56d04 100644 --- a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx +++ b/plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import React, { ReactNode } from 'react'; -import { EntityContext } from '../../hooks/useEntity'; +import { EntityContext } from '../../hooks'; type EntityProviderProps = { entity: Entity; diff --git a/plugins/catalog/src/components/EntityProvider/index.ts b/plugins/catalog-react/src/components/EntityProvider/index.ts similarity index 100% rename from plugins/catalog/src/components/EntityProvider/index.ts rename to plugins/catalog-react/src/components/EntityProvider/index.ts diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx new file mode 100644 index 0000000000..b6fe8a77d8 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.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 { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { EntityRefLink } from './EntityRefLink'; + +describe('', () => { + it('renders link for entity in default namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + }); + + it('renders link for entity in other namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText('component:test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); + + it('renders link for entity and hides default kind', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const { getByText } = render( + , + { + wrapper: MemoryRouter, + }, + ); + expect(getByText('test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); + + it('renders link for entity name in default namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'default', + name: 'software', + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + }); + + it('renders link for entity name in other namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText('component:test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); + + it('renders link for entity name and hides default kind', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + const { getByText } = render( + , + { + wrapper: MemoryRouter, + }, + ); + expect(getByText('test/software')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); + + it('renders link with custom children', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + const { getByText } = render( + + Custom Children + , + { + wrapper: MemoryRouter, + }, + ); + expect(getByText('Custom Children')).toHaveAttribute( + 'href', + '/catalog/test/component/software', + ); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx new file mode 100644 index 0000000000..ae6f807275 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.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 { + Entity, + EntityName, + ENTITY_DEFAULT_NAMESPACE, +} from '@backstage/catalog-model'; +import { Link } from '@material-ui/core'; +import React from 'react'; +import { generatePath } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; +import { entityRoute } from '../../routes'; +import { formatEntityRefTitle } from './format'; + +type EntityRefLinkProps = { + entityRef: Entity | EntityName; + defaultKind?: string; + children?: React.ReactNode; +}; + +export const EntityRefLink = ({ + entityRef, + defaultKind, + children, +}: EntityRefLinkProps) => { + let kind; + let namespace; + let name; + + if ('metadata' in entityRef) { + kind = entityRef.kind; + namespace = entityRef.metadata.namespace; + name = entityRef.metadata.name; + } else { + kind = entityRef.kind; + namespace = entityRef.namespace; + name = entityRef.name; + } + + kind = kind.toLowerCase(); + + const routeParams = { + kind, + namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + name, + }; + + // TODO: Use useRouteRef here to generate the path + return ( + + {children} + {!children && formatEntityRefTitle(entityRef, { defaultKind })} + + ); +}; diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx new file mode 100644 index 0000000000..9bc26b0954 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.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 { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { EntityRefLinks } from './EntityRefLinks'; + +describe('', () => { + it('renders a single link', () => { + const entityNames = [ + { + kind: 'Component', + namespace: 'default', + name: 'software', + }, + ]; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + }); + + it('renders multiple links', () => { + const entityNames = [ + { + kind: 'Component', + namespace: 'default', + name: 'software', + }, + { + kind: 'API', + namespace: 'default', + name: 'interface', + }, + ]; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + expect(getByText(',')).toBeInTheDocument(); + expect(getByText('component:software')).toHaveAttribute( + 'href', + '/catalog/default/component/software', + ); + expect(getByText('api:interface')).toHaveAttribute( + 'href', + '/catalog/default/api/interface', + ); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx new file mode 100644 index 0000000000..fb99fad176 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.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 { Entity, EntityName } from '@backstage/catalog-model'; +import React from 'react'; +import { EntityRefLink } from './EntityRefLink'; + +type EntityRefLinksProps = { + entityRefs: (Entity | EntityName)[]; + defaultKind?: string; +}; + +export const EntityRefLinks = ({ + entityRefs, + defaultKind, +}: EntityRefLinksProps) => { + return ( + <> + {entityRefs.map((r, i) => ( + + {i > 0 && ', '} + + + ))} + + ); +}; diff --git a/plugins/catalog-react/src/components/EntityRefLink/format.test.ts b/plugins/catalog-react/src/components/EntityRefLink/format.test.ts new file mode 100644 index 0000000000..142c914453 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityRefLink/format.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { formatEntityRefTitle } from './format'; + +describe('formatEntityRefTitle', () => { + it('formats entity in default namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const title = formatEntityRefTitle(entity); + expect(title).toEqual('component:software'); + }); + + it('formats entity in other namespace', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const title = formatEntityRefTitle(entity); + expect(title).toEqual('component:test/software'); + }); + + it('formats entity and hides default kind', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + namespace: 'test', + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const title = formatEntityRefTitle(entity, { defaultKind: 'Component' }); + expect(title).toEqual('test/software'); + }); + + it('formats entity name in default namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'default', + name: 'software', + }; + const title = formatEntityRefTitle(entityName); + expect(title).toEqual('component:software'); + }); + + it('formats entity name in other namespace', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + + const title = formatEntityRefTitle(entityName); + expect(title).toEqual('component:test/software'); + }); + + it('renders link for entity name and hides default kind', () => { + const entityName = { + kind: 'Component', + namespace: 'test', + name: 'software', + }; + + const title = formatEntityRefTitle(entityName, { + defaultKind: 'component', + }); + expect(title).toEqual('test/software'); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityRefLink/format.ts b/plugins/catalog-react/src/components/EntityRefLink/format.ts new file mode 100644 index 0000000000..28ba1bd22d --- /dev/null +++ b/plugins/catalog-react/src/components/EntityRefLink/format.ts @@ -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 { + Entity, + EntityName, + ENTITY_DEFAULT_NAMESPACE, + serializeEntityRef, +} from '@backstage/catalog-model'; + +export function formatEntityRefTitle( + entityRef: Entity | EntityName, + opts?: { defaultKind?: string }, +) { + const defaultKind = opts?.defaultKind; + let kind; + let namespace; + let name; + + if ('metadata' in entityRef) { + kind = entityRef.kind; + namespace = entityRef.metadata.namespace; + name = entityRef.metadata.name; + } else { + kind = entityRef.kind; + namespace = entityRef.namespace; + name = entityRef.name; + } + + if (namespace === ENTITY_DEFAULT_NAMESPACE) { + namespace = undefined; + } + + kind = kind.toLowerCase(); + + return `${serializeEntityRef({ + kind: defaultKind && defaultKind.toLowerCase() === kind ? undefined : kind, + name, + namespace, + })}`; +} diff --git a/plugins/catalog-react/src/components/EntityRefLink/index.ts b/plugins/catalog-react/src/components/EntityRefLink/index.ts new file mode 100644 index 0000000000..9e6e440514 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityRefLink/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 { EntityRefLink } from './EntityRefLink'; +export { EntityRefLinks } from './EntityRefLinks'; +export { formatEntityRefTitle } from './format'; diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts new file mode 100644 index 0000000000..fcfa8207b3 --- /dev/null +++ b/plugins/catalog-react/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './EntityRefLink'; +export * from './EntityProvider'; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts new file mode 100644 index 0000000000..77de70b8d4 --- /dev/null +++ b/plugins/catalog-react/src/hooks/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 { EntityContext, useEntity, useEntityFromUrl } from './useEntity'; +export { useEntityCompoundName } from './useEntityCompoundName'; diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog-react/src/hooks/useEntity.ts similarity index 94% rename from plugins/catalog/src/hooks/useEntity.ts rename to plugins/catalog-react/src/hooks/useEntity.ts index 289c4e26aa..faeeca11ed 100644 --- a/plugins/catalog/src/hooks/useEntity.ts +++ b/plugins/catalog-react/src/hooks/useEntity.ts @@ -18,8 +18,8 @@ import { errorApiRef, useApi } from '@backstage/core'; import { createContext, useContext, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useAsync } from 'react-use'; -import { useEntityCompoundName } from '../components/useEntityCompoundName'; -import { catalogApiRef } from '../plugin'; +import { useEntityCompoundName } from './useEntityCompoundName'; +import { catalogApiRef } from '../api'; type EntityLoadingStatus = { entity?: Entity; diff --git a/plugins/catalog/src/components/useEntityCompoundName.ts b/plugins/catalog-react/src/hooks/useEntityCompoundName.ts similarity index 100% rename from plugins/catalog/src/components/useEntityCompoundName.ts rename to plugins/catalog-react/src/hooks/useEntityCompoundName.ts diff --git a/plugins/catalog-react/src/index.ts b/plugins/catalog-react/src/index.ts new file mode 100644 index 0000000000..af3eca4e0a --- /dev/null +++ b/plugins/catalog-react/src/index.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. + */ +export type { CatalogApi } from '@backstage/catalog-client'; +export { catalogApiRef } from './api'; +export * from './components'; +export * from './hooks'; +export { + catalogRouteRef, + entityRoute, + entityRouteParams, + entityRouteRef, + rootRoute, +} from './routes'; +export * from './utils'; diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog-react/src/routes.ts similarity index 92% rename from plugins/catalog/src/routes.ts rename to plugins/catalog-react/src/routes.ts index 2983b464ac..0fced32f59 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog-react/src/routes.ts @@ -19,6 +19,7 @@ import { createRouteRef } from '@backstage/core'; const NoIcon = () => null; +// TODO(Rugvip): Move these route refs back to the catalog plugin once we're all ported to using external routes export const rootRoute = createRouteRef({ icon: NoIcon, path: '', diff --git a/plugins/catalog-react/src/setupTests.ts b/plugins/catalog-react/src/setupTests.ts new file mode 100644 index 0000000000..aea2220869 --- /dev/null +++ b/plugins/catalog-react/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'; +import 'cross-fetch/polyfill'; diff --git a/plugins/catalog/src/components/getEntityRelations.test.ts b/plugins/catalog-react/src/utils/getEntityRelations.test.ts similarity index 100% rename from plugins/catalog/src/components/getEntityRelations.test.ts rename to plugins/catalog-react/src/utils/getEntityRelations.test.ts diff --git a/plugins/catalog/src/components/getEntityRelations.ts b/plugins/catalog-react/src/utils/getEntityRelations.ts similarity index 100% rename from plugins/catalog/src/components/getEntityRelations.ts rename to plugins/catalog-react/src/utils/getEntityRelations.ts diff --git a/plugins/catalog-react/src/utils/index.ts b/plugins/catalog-react/src/utils/index.ts new file mode 100644 index 0000000000..2efb35703e --- /dev/null +++ b/plugins/catalog-react/src/utils/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 { getEntityRelations } from './getEntityRelations'; +export { isOwnerOf } from './isOwnerOf'; diff --git a/plugins/catalog/src/components/isOwnerOf.test.ts b/plugins/catalog-react/src/utils/isOwnerOf.test.ts similarity index 100% rename from plugins/catalog/src/components/isOwnerOf.test.ts rename to plugins/catalog-react/src/utils/isOwnerOf.test.ts diff --git a/plugins/catalog/src/components/isOwnerOf.ts b/plugins/catalog-react/src/utils/isOwnerOf.ts similarity index 83% rename from plugins/catalog/src/components/isOwnerOf.ts rename to plugins/catalog-react/src/utils/isOwnerOf.ts index 455e06dc13..d135e4030a 100644 --- a/plugins/catalog/src/components/isOwnerOf.ts +++ b/plugins/catalog-react/src/utils/isOwnerOf.ts @@ -34,13 +34,13 @@ export function isOwnerOf(owner: Entity, owned: Entity) { const owners = getEntityRelations(owned, RELATION_OWNED_BY); - for (const owner of owners) { + for (const ownerItem of owners) { if ( possibleOwners.find( o => - owner.kind.toLowerCase() === o.kind.toLowerCase() && - owner.namespace.toLowerCase() === o.namespace.toLowerCase() && - owner.name.toLowerCase() === o.name.toLowerCase(), + ownerItem.kind.toLowerCase() === o.kind.toLowerCase() && + ownerItem.namespace.toLowerCase() === o.namespace.toLowerCase() && + ownerItem.name.toLowerCase() === o.name.toLowerCase(), ) !== undefined ) { return true; diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index ce5b978e7d..eb4c9a30d7 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,122 @@ # @backstage/plugin-catalog +## 0.3.0 + +### Minor Changes + +- 019fe39a0: `@backstage/plugin-catalog` stopped exporting hooks and helpers for other + plugins. They are migrated to `@backstage/plugin-catalog-react`. + Change both your dependencies and imports to the new package. + +### Patch Changes + +- 7fc89bae2: Display owner and system as entity page links in the tables of the `api-docs` + plugin. + + Move `isOwnerOf` and `getEntityRelations` from `@backstage/plugin-catalog` to + `@backstage/plugin-catalog-react` and export it from there to use it by other + plugins. + +- b37501a3d: Add `children` option to `addPage`, which will be rendered as the children of the `Route`. +- b37501a3d: Finalize migration to new composability API, with the plugin instance now exported `catalogPlugin`. +- 54c7d02f7: Introduce `TabbedLayout` for creating tabs that are routed. + + ```typescript + + +
This is rendered under /example/anything-here route
+
+
+ ``` + +- Updated dependencies [720149854] +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [019fe39a0] +- Updated dependencies [11cb5ef94] + - @backstage/plugin-scaffolder@0.4.2 + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.2.14 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- 0b1182346: Add `EntityRefLinks` that shows one or multiple entity ref links. + + Change the about card and catalog table to use `EntityRefLinks` due to the + nature of relations to support multiple relations per type. + +- Updated dependencies [9dd057662] + - @backstage/plugin-scaffolder@0.4.1 + +## 0.2.13 + +### Patch Changes + +- a4e636c8f: Hide the kind of owners in the about card if it's the default kind (group) +- 099c5cf4f: Show the parent component in the about card (via partOf relationship) +- a08db734c: Remove the `WelcomeBanner` that links to a plugin that is not longer wired into Backstage instances + +## 0.2.12 + +### Patch Changes + +- 593632f07: Derive the list of to-delete entities in the `UnregisterEntityDialog` from the `backstage.io/managed-by-origin-location` annotation. + The dialog also rejects deleting entities that are created by the `bootstrap:bootstrap` location. +- 33846acfc: Display the owner, system, and domain as links to the entity pages in the about card. + Only display fields in the about card that are applicable to the entity kind. +- f04db53d7: Display systems in catalog table and make both owner and system link to the entity pages. + The owner field is now taken from the relations of the entity instead of its spec. +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] +- Updated dependencies [ed6baab66] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-scaffolder@0.4.0 + - @backstage/catalog-client@0.3.5 + +## 0.2.11 + +### Patch Changes + +- c00488983: Enable catalog table actions for all location types. + + The edit button has had support for other providers for a while and there is + no specific reason the View in GitHub cannot work for all locations. This + change also replaces the GitHub icon with the OpenInNew icon. + +- Updated dependencies [f3b064e1c] +- Updated dependencies [265a7ab30] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/core@0.4.4 + +## 0.2.10 + +### Patch Changes + +- 9c09a364f: Remove the unused dependency to `@backstage/plugin-techdocs`. +- Updated dependencies [8e083f41f] +- Updated dependencies [947d3c269] + - @backstage/plugin-scaffolder@0.3.6 + ## 0.2.9 ### Patch Changes diff --git a/plugins/catalog/dev/index.tsx b/plugins/catalog/dev/index.tsx index 812a5585d4..34f23071b0 100644 --- a/plugins/catalog/dev/index.tsx +++ b/plugins/catalog/dev/index.tsx @@ -14,7 +14,31 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { + catalogPlugin, + CatalogIndexPage, + CatalogEntityPage, + EntityLayout, +} from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerPlugin(catalogPlugin) + .addPage({ + path: '/catalog', + title: 'Catalog', + element: , + }) + .addPage({ + path: '/catalog/:namespace/:kind/:name', + element: , + children: ( + + +

Overview

+
+
+ ), + }) + .render(); diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 033e074fdd..c806c58e67 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.2.9", + "version": "0.3.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,17 +30,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.4", - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-scaffolder": "^0.3.5", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-client": "^0.3.5", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/plugin-scaffolder": "^0.4.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@types/react": "^16.9", "classnames": "^2.2.6", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -51,8 +52,8 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index b8d5d84316..75b7827111 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -14,15 +14,18 @@ * limitations under the License. */ -import React from 'react'; -import { Grid, Typography, Chip, makeStyles } from '@material-ui/core'; -import { AboutField } from './AboutField'; import { Entity, - ENTITY_DEFAULT_NAMESPACE, RELATION_OWNED_BY, - serializeEntityRef, + RELATION_PART_OF, } from '@backstage/catalog-model'; +import { + EntityRefLinks, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import { Chip, Grid, makeStyles, Typography } from '@material-ui/core'; +import React from 'react'; +import { AboutField } from './AboutField'; const useStyles = makeStyles({ description: { @@ -36,6 +39,25 @@ type Props = { export const AboutContent = ({ entity }: Props) => { const classes = useStyles(); + const isSystem = entity.kind.toLowerCase() === 'system'; + const isDomain = entity.kind.toLowerCase() === 'domain'; + const isResource = entity.kind.toLowerCase() === 'resource'; + const isComponent = entity.kind.toLowerCase() === 'component'; + const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'system', + }); + const partOfComponentRelations = getEntityRelations( + entity, + RELATION_PART_OF, + { + kind: 'component', + }, + ); + const partOfDomainRelations = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'domain', + }); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + return ( @@ -43,32 +65,59 @@ export const AboutContent = ({ entity }: Props) => { {entity?.metadata?.description || 'No description'} - r.type === RELATION_OWNED_BY) - .map(({ target: { kind, name, namespace } }) => - // TODO(Rugvip): we want to provide some utils for this - serializeEntityRef({ - kind, - name, - namespace: - namespace === ENTITY_DEFAULT_NAMESPACE ? undefined : namespace, - }), - ) - .join(', ')} - gridSizes={{ xs: 12, sm: 6, lg: 4 }} - /> - - + + + + {isSystem && ( + + + + )} + {!isSystem && !isDomain && ( + + + + )} + {isComponent && partOfComponentRelations.length > 0 && ( + + + + )} + {!isSystem && !isDomain && ( + + )} + {!isSystem && !isDomain && !isResource && ( + + )} { const theme = useTheme(); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index b92fc3d4bc..0cc2108985 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -23,11 +23,11 @@ import { identityApiRef, storageApiRef, } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; import { ButtonGroup, CatalogFilter } from './CatalogFilter'; describe('Catalog Filter', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 28fb1b08c7..42c9cf1117 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -28,11 +28,11 @@ import { ProfileInfo, storageApiRef, } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; import { CatalogPage } from './CatalogPage'; describe('CatalogPage', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 077e1f092f..1298d5e190 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -22,6 +22,7 @@ import { SupportButton, useApi, } from '@backstage/core'; +import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; import { Button, makeStyles } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; @@ -30,15 +31,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { useStarredEntities } from '../../hooks/useStarredEntities'; -import { catalogApiRef } from '../../plugin'; import { ButtonGroup, CatalogFilter } from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; +import { useOwnUser } from '../useOwnUser'; import CatalogLayout from './CatalogLayout'; import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; -import { WelcomeBanner } from './WelcomeBanner'; -import { useOwnUser } from '../useOwnUser'; -import { isOwnerOf } from '../isOwnerOf'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -157,7 +155,6 @@ const CatalogPageContents = () => { onChange={({ label }) => setSelectedTab(label)} /> - + ) : ( + + )} + + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx new file mode 100644 index 0000000000..98f75b1d41 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.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 { render, fireEvent, waitFor } from '@testing-library/react'; +import { AlertInsights } from './AlertInsights'; +import { MockScrollProvider, MockLoadingProvider } from '../../utils/tests'; + +function renderInContext(children: JSX.Element) { + return render( + + {children} + , + ); +} + +describe('', () => { + it('should display the correct header if there are active action items', () => { + const { getByText, queryByText } = renderInContext( + , + ); + expect( + getByText( + 'This section outlines suggested action items your team can address to improve cloud costs.', + ), + ).toBeInTheDocument(); + expect(queryByText('Hidden Action Item')).not.toBeInTheDocument(); + }); + + it('should display alert summary if there are hidden action items', async () => { + const { getByText, getByRole } = renderInContext( + , + ); + + expect( + getByText( + "All of your team's action items are hidden. Maybe it's time to give them another look?", + ), + ).toBeInTheDocument(); + expect(getByText('Hidden Action Item')).toBeInTheDocument(); + + fireEvent.click(getByRole('button', { name: 'expand' })); + await waitFor(() => getByRole('img', { name: 'snoozed' })); + + expect(getByText('Vol. 4')).toBeInTheDocument(); + expect(getByText('Sabotage')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx index 4606906f04..7360c99cf7 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -14,31 +14,214 @@ * limitations under the License. */ -import React from 'react'; -import { Grid } from '@material-ui/core'; -import { AlertInsightsSection } from './AlertInsightsSection'; +import React, { useEffect, useState } from 'react'; +import pluralize from 'pluralize'; +import { Box, Grid, Snackbar } from '@material-ui/core'; +import { default as MuiAlert } from '@material-ui/lab/Alert'; +import { AlertDialog } from './AlertDialog'; +import { AlertStatusSummary } from './AlertStatusSummary'; +import { AlertStatusSummaryButton } from './AlertStatusSummaryButton'; import { AlertInsightsHeader } from './AlertInsightsHeader'; -import { Alert } from '../../types'; +import { AlertInsightsSection } from './AlertInsightsSection'; +import { + useScroll, + useLoading, + ScrollType, + MapLoadingToProps, +} from '../../hooks'; +import { DefaultLoadingAction } from '../../utils/loading'; +import { Alert, AlertOptions, AlertStatus, Maybe } from '../../types'; +import { + isStatusSnoozed, + isStatusAccepted, + isStatusDismissed, + sumOfAllAlerts, +} from '../../utils/alerts'; +import { ScrollAnchor } from '../../utils/scroll'; -const title = "Your team's action items"; -const subtitle = - 'This section outlines suggested action items your team can address to improve cloud costs.'; +type MapLoadingtoAlerts = (isLoading: boolean) => void; + +const mapLoadingToAlerts: MapLoadingToProps = ({ + dispatch, +}) => (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsAlerts]: isLoading }); type AlertInsightsProps = { - alerts: Array; + group: string; + active: Alert[]; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; + onChange: (alerts: Alert[]) => void; }; -export const AlertInsights = ({ alerts }: AlertInsightsProps) => ( - - - - - - {alerts.map((alert, index) => ( - - +export const AlertInsights = ({ + group, + active, + snoozed, + accepted, + dismissed, + onChange, +}: AlertInsightsProps) => { + const [scroll] = useScroll(); + const [alert, setAlert] = useState>(null); + const dispatchLoadingAlerts = useLoading(mapLoadingToAlerts); + const [status, setStatus] = useState>(null); + // Allow users to pass null values for data. + const [data, setData] = useState>(undefined); + const [error, setError] = useState>(null); + const [isDialogOpen, setDialogOpen] = useState(false); + const [isSummaryOpen, setSummaryOpen] = useState(false); + const [isSnackbarOpen, setSnackbarOpen] = useState(false); + + useEffect(() => { + async function callAlertHook( + options: AlertOptions, + callback: (options: AlertOptions) => Promise, + ) { + setAlert(null); + setStatus(null); + setData(undefined); + setDialogOpen(false); + dispatchLoadingAlerts(true); + try { + const alerts: Alert[] = await callback(options); + onChange(alerts); + } catch (e) { + setError(e); + } finally { + dispatchLoadingAlerts(false); + } + } + + const options: AlertOptions = { data, group }; + const onSnoozed = alert?.onSnoozed?.bind(alert); + const onAccepted = alert?.onAccepted?.bind(alert); + const onDismissed = alert?.onDismissed?.bind(alert); + + if (data !== undefined) { + if (isStatusSnoozed(status) && onSnoozed) { + callAlertHook(options, onSnoozed); + } else if (isStatusAccepted(status) && onAccepted) { + callAlertHook(options, onAccepted); + } else if (isStatusDismissed(status) && onDismissed) { + callAlertHook(options, onDismissed); + } + } + }, [group, data, alert, status, onChange, dispatchLoadingAlerts]); + + useEffect(() => { + if (scroll === ScrollType.AlertSummary) { + setSummaryOpen(true); + } + }, [scroll]); + + useEffect(() => { + setDialogOpen(!!status); + }, [status]); + + useEffect(() => { + setSnackbarOpen(!!error); + }, [error]); + + function onSnooze(alertToSnooze: Alert) { + setAlert(alertToSnooze); + setStatus(AlertStatus.Snoozed); + } + + function onAccept(alertToAccept: Alert) { + setAlert(alertToAccept); + setStatus(AlertStatus.Accepted); + } + + function onDismiss(alertToDismiss: Alert) { + setAlert(alertToDismiss); + setStatus(AlertStatus.Dismissed); + } + + function onSnackbarClose() { + setError(null); + } + + function onDialogClose() { + setAlert(null); + setStatus(null); + } + + function onDialogFormSubmit(formData: any) { + setData(formData); + } + + function onSummaryButtonClick() { + setSummaryOpen(prevOpen => !prevOpen); + } + + const total = [accepted, snoozed, dismissed].reduce(sumOfAllAlerts, 0); + + const isAlertStatusSummaryDisplayed = !!total; + const isAlertInsightSectionDisplayed = !!active.length; + + return ( + + + + + {isAlertInsightSectionDisplayed && ( + + {active.map((activeAlert, index) => ( + + + + ))} - ))} + )} + {isAlertStatusSummaryDisplayed && ( + + + + + {pluralize('Hidden Action Item', total)} + + + + + )} + + + + {error?.message} + + - -); + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx index b58f6973ae..86a4334d67 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Box, Typography } from '@material-ui/core'; import { useCostInsightsStyles as useStyles } from '../../utils/styles'; -import { useScroll } from '../../hooks'; +import { ScrollAnchor } from '../../utils/scroll'; import { DefaultNavigation } from '../../utils/navigation'; type AlertInsightsHeaderProps = { @@ -30,10 +30,10 @@ export const AlertInsightsHeader = ({ subtitle, }: AlertInsightsHeaderProps) => { const classes = useStyles(); - const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader); + return ( - + {title}{' '} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx index 54d350298c..937e099356 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -17,38 +17,115 @@ import React from 'react'; import { AlertInsightsSection } from './AlertInsightsSection'; import { render } from '@testing-library/react'; import { Alert } from '../../types'; -import { MockScrollProvider } from '../..'; +import { MockScrollProvider } from '../../utils/tests'; const mockAlert: Alert = { - element:
, subtitle: 'Wherefore was I to this keen mockery born? When at your hands did I deserve this scorn?', title: 'Mock alert', url: '/cost-insights/test', }; +function renderInContext(children: JSX.Element) { + return render({children}); +} + describe('', () => { it('Renders alert without exploding', () => { - const { getByText } = render( - - - , + const { getByText, queryByText } = renderInContext( + , ); expect(getByText(mockAlert.title)).toBeInTheDocument(); expect(getByText(mockAlert.subtitle)).toBeInTheDocument(); expect(getByText('View Instructions')).toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + expect(queryByText('Accept')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); }); it('Hides instructions button if url is not provided', () => { - const alert = { + const alert: Alert = { ...mockAlert, url: undefined, }; - const { queryByText } = render( - - - , + const { queryByText } = renderInContext( + , ); expect(queryByText('View Instructions')).not.toBeInTheDocument(); }); + + it('Displays a snooze button if a hook is provided', () => { + const alert: Alert = { + ...mockAlert, + onSnoozed: jest.fn(), + }; + + const { queryByText, getByText } = renderInContext( + , + ); + + expect(getByText('Snooze')).toBeInTheDocument(); + expect(queryByText('Accept')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); + }); + + it('Displays a dismiss button if a hook is provided', () => { + const alert: Alert = { + ...mockAlert, + onDismissed: jest.fn(), + }; + + const { queryByText, getByText } = renderInContext( + , + ); + + expect(getByText('Dismiss')).toBeInTheDocument(); + expect(queryByText('Accept')).not.toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + }); + + it('Displays an accept button if a hook is provided', () => { + const alert: Alert = { + ...mockAlert, + onAccepted: jest.fn(), + }; + + const { queryByText, getByText } = renderInContext( + , + ); + + expect(getByText('Accept')).toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); + }); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx index 7e371c8001..d52687ae19 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -15,30 +15,84 @@ */ import React from 'react'; import { Box, Button } from '@material-ui/core'; +import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; +import { default as AcceptIcon } from '@material-ui/icons/Check'; +import { default as DismissIcon } from '@material-ui/icons/Delete'; import { AlertInsightsSectionHeader } from './AlertInsightsSectionHeader'; import { Alert } from '../../types'; +import { + isSnoozeEnabled, + isAcceptEnabled, + isDismissEnabled, +} from '../../utils/alerts'; type AlertInsightsSectionProps = { alert: Alert; number: number; + onSnooze: (alert: Alert) => void; + onAccept: (alert: Alert) => void; + onDismiss: (alert: Alert) => void; }; export const AlertInsightsSection = ({ alert, number, + onSnooze, + onAccept, + onDismiss, }: AlertInsightsSectionProps) => { + const isSnoozeButtonDisplayed = isSnoozeEnabled(alert); + const isAcceptButtonDisplayed = isAcceptEnabled(alert); + const isDismissButtonDisplayed = isDismissEnabled(alert); + const isButtonGroupDisplayed = + isSnoozeButtonDisplayed || + isAcceptButtonDisplayed || + isDismissButtonDisplayed; + return ( - - - {alert.url && ( - - + + + {isButtonGroupDisplayed && ( + + {isAcceptButtonDisplayed && ( + + + + )} + {isSnoozeButtonDisplayed && ( + + + + )} + {isDismissButtonDisplayed && ( + + )} )} {alert.element} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx index 3613e3adc8..30b8985934 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx @@ -15,34 +15,46 @@ */ import React from 'react'; -import { Avatar, Box, Grid, Typography } from '@material-ui/core'; +import { Avatar, Box, Button, Grid, Typography } from '@material-ui/core'; import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles'; -import { useScroll } from '../../hooks'; +import { ScrollAnchor } from '../../utils/scroll'; +import { Alert } from '../../types'; type AlertInsightsSectionHeaderProps = { + alert: Alert; number: number; - title: string; - subtitle: string; }; export const AlertInsightsSectionHeader = ({ + alert, number, - title, - subtitle, }: AlertInsightsSectionHeaderProps) => { - const { ScrollAnchor } = useScroll(`alert-${number}`); const classes = useStyles(); + + const isViewInstructionsButtonDisplayed = !!alert.url; + return ( - - + + - {number} - - - {title} - {subtitle} + + + {number} + + + {alert.title} + {alert.subtitle} + + + {isViewInstructionsButtonDisplayed && ( + + + + )} ); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx new file mode 100644 index 0000000000..830db19fcd --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { AlertStatusSummary } from './AlertStatusSummary'; +import { render } from '@testing-library/react'; +import { Alert, AlertStatus } from '../../types'; +import { MockScrollProvider } from '../../utils/tests'; + +const mockSnoozed: Alert = { + title: 'snoozed-title', + subtitle: 'snoozed-subtitle', + status: AlertStatus.Snoozed, +}; + +const mockAccepted: Alert = { + title: 'accepted-title', + subtitle: 'accepted-subtitle', + status: AlertStatus.Accepted, +}; + +const mockDismissed: Alert = { + title: 'dismissed-title', + subtitle: 'dismissed-subtitle', + status: AlertStatus.Dismissed, +}; + +describe('', () => { + it('should display alerts', () => { + const { getByText, getByRole } = render( + + + , + ); + [mockSnoozed, mockAccepted, mockDismissed].forEach(a => { + expect(getByText(a.title)).toBeInTheDocument(); + expect(getByText(a.subtitle)).toBeInTheDocument(); + expect(getByRole('img', { name: a.status })).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx new file mode 100644 index 0000000000..caee99dc5e --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { Avatar, Box, Collapse, Divider, Tooltip } from '@material-ui/core'; +import { default as AcceptIcon } from '@material-ui/icons/Check'; +import { default as DismissIcon } from '@material-ui/icons/Delete'; +import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; +import { ActionItemCard } from '../ActionItems'; +import { Alert, AlertStatus } from '../../types'; +import { useActionItemCardStyles as useStyles } from '../../utils/styles'; + +type AlertGroupProps = { + alerts: Alert[]; + status: AlertStatus; + title: string; + icon: JSX.Element; +}; + +const AlertGroup = ({ alerts, status, title, icon }: AlertGroupProps) => { + const classes = useStyles(); + return ( + + {alerts.map((alert, index) => ( + + + {icon} + + } + /> + {index < alerts.length - 1 && } + + ))} + + ); +}; + +type AlertStatusSummaryProps = { + open: boolean; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; +}; + +export const AlertStatusSummary = ({ + open, + snoozed, + accepted, + dismissed, +}: AlertStatusSummaryProps) => { + const isSnoozedListDisplayed = !!snoozed.length; + const isAcceptedListDisplayed = !!accepted.length; + const isDismissedListDisplayed = !!dismissed.length; + + return ( + + {isAcceptedListDisplayed && ( + + } + /> + )} + {isSnoozedListDisplayed && ( + + } + /> + )} + {isDismissedListDisplayed && ( + + } + /> + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx new file mode 100644 index 0000000000..ed8c7fabbb --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.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, { useState, MouseEventHandler, PropsWithChildren } from 'react'; +import classnames from 'classnames'; +import { Button } from '@material-ui/core'; +import { default as ExpandMoreIcon } from '@material-ui/icons/ExpandMore'; +import { useAlertStatusSummaryButtonStyles as useStyles } from '../../utils/styles'; + +type AlertStatusSummaryButtonProps = { + onClick: MouseEventHandler; +}; + +export const AlertStatusSummaryButton = ({ + children, + onClick, +}: PropsWithChildren) => { + const classes = useStyles(); + const [clicked, setClicked] = useState(false); + const iconClassName = classnames(classes.icon, { + [classes.clicked]: clicked, + }); + + const handleOnClick: MouseEventHandler = e => { + setClicked(prevClicked => !prevClicked); + onClick(e); + }; + + return ( + + ); +}; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx index c4402893f3..1963b45f48 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -18,7 +18,7 @@ import React, { PropsWithChildren } from 'react'; import { Box, Typography } from '@material-ui/core'; import { useBarChartLabelStyles } from '../../utils/styles'; -type BarChartLabel = { +type BarChartLabelProps = { x: number; y: number; height: number; @@ -33,7 +33,7 @@ export const BarChartLabel = ({ width, details, children, -}: PropsWithChildren) => { +}: PropsWithChildren) => { const classes = useBarChartLabelStyles(); const translateX = width * -0.5; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx index 11a295965e..8157e05444 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { ButtonBase } from '@material-ui/core'; import { useBarChartStepperStyles as useStyles } from '../../utils/styles'; -export type BarChartSteps = { +export type BarChartStepsProps = { steps: number; activeStep: number; onClick: (index: number) => void; @@ -28,7 +28,7 @@ export const BarChartSteps = ({ steps, activeStep, onClick, -}: BarChartSteps) => { +}: BarChartStepsProps) => { const classes = useStyles(); const handleOnClick = (index: number) => ( event: React.MouseEvent, diff --git a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx index 5e6de017d1..b3c5be94ba 100644 --- a/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx +++ b/plugins/cost-insights/src/components/CostInsightsHeader/CostInsightsHeader.tsx @@ -31,16 +31,6 @@ type CostInsightsHeaderProps = { alerts: number; }; -export const CostInsightsHeader = (props: CostInsightsHeaderProps) => { - if (!props.hasCostData) { - return ; - } - if (props.alerts) { - return ; - } - return ; -}; - const CostInsightsHeaderNoData = ({ owner, groups, @@ -132,3 +122,13 @@ export const CostInsightsHeaderNoGroups = () => { ); }; + +export const CostInsightsHeader = (props: CostInsightsHeaderProps) => { + if (!props.hasCostData) { + return ; + } + if (props.alerts) { + return ; + } + return ; +}; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx index a30b6c5a90..1e2bd8bb6b 100644 --- a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx @@ -39,6 +39,24 @@ type CostInsightsNavigationProps = { products: Maybe; }; +const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => { + const classes = useStyles(); + const [, setScroll] = useScroll(); + return ( + setScroll(navigation)} + > + {icon} + {title}} + /> + + ); +}; + export const CostInsightsNavigation = React.memo( ({ alerts, products }: CostInsightsNavigationProps) => { const classes = useStyles(); @@ -102,21 +120,3 @@ export const CostInsightsNavigation = React.memo( ); }, ); - -const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => { - const classes = useStyles(); - const { scrollIntoView } = useScroll(navigation); - return ( - - {icon} - {title}} - /> - - ); -}; diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index 9a38bf2955..a8ba454d11 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -14,12 +14,19 @@ * limitations under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; -import { Box, Container, Divider, Grid, Typography } from '@material-ui/core'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Box, + Collapse, + Container, + Divider, + Grid, + Typography, +} from '@material-ui/core'; import { Progress, useApi } from '@backstage/core'; import { default as MaterialAlert } from '@material-ui/lab/Alert'; import { costInsightsApiRef } from '../../api'; -import { AlertActionCardList } from '../AlertActionCardList'; +import { ActionItems } from '../ActionItems'; import { AlertInsights } from '../AlertInsights'; import { CostInsightsLayout } from '../CostInsightsLayout'; import { CopyUrlToClipboard } from '../CopyUrlToClipboard'; @@ -47,6 +54,12 @@ import { mapLoadingToProps } from './selector'; import { ProjectSelect } from '../ProjectSelect'; import { intervalsOf } from '../../utils/duration'; import { useSubtleTypographyStyles } from '../../utils/styles'; +import { + isAlertActive, + isAlertAccepted, + isAlertDismissed, + isAlertSnoozed, +} from '../../utils/alerts'; export const CostInsightsPage = () => { const classes = useSubtleTypographyStyles(); @@ -54,16 +67,24 @@ export const CostInsightsPage = () => { const config = useConfig(); const groups = useGroups(); const lastCompleteBillingDate = useLastCompleteBillingDate(); + const [alerts, setAlerts] = useState([]); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); const [products, setProducts] = useState>(null); const [dailyCost, setDailyCost] = useState>(null); const [metricData, setMetricData] = useState>(null); - const [alerts, setAlerts] = useState>(null); const [error, setError] = useState>(null); const { pageFilters, setPageFilters } = useFilters(p => p); + const active = useMemo(() => alerts.filter(isAlertActive), [alerts]); + const snoozed = useMemo(() => alerts.filter(isAlertSnoozed), [alerts]); + const accepted = useMemo(() => alerts.filter(isAlertAccepted), [alerts]); + const dismissed = useMemo(() => alerts.filter(isAlertDismissed), [alerts]); + + const isActionItemsDisplayed = !!active.length; + const isAlertInsightsDisplayed = !!alerts.length; + const { loadingActions, loadingGroups, @@ -177,8 +198,8 @@ export const CostInsightsPage = () => { ); } - // These should be defined, alerts can be an empty array but that's truthy - if (!dailyCost || !alerts) { + + if (!dailyCost) { return ( {`Error: Could not fetch cost insights data for team ${pageFilters.group}`} ); @@ -228,7 +249,7 @@ export const CostInsightsPage = () => { @@ -249,19 +270,22 @@ export const CostInsightsPage = () => { owner={pageFilters.group} groups={groups} hasCostData={!!dailyCost.aggregation.length} - alerts={alerts.length} + alerts={active.length} /> - {!!alerts.length && ( - <> - - - - - - - - )} + + + + + + + + @@ -276,14 +300,21 @@ export const CostInsightsPage = () => { - - {!!alerts?.length && ( + + - + - )} - - {!alerts.length && } + + + {!isAlertInsightsDisplayed && } { + const theme = useTheme(); + const classes = useStyles(theme); + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const { duration } = useFilters(mapFiltersToProps); + const [isExpanded, setExpanded] = useState(false); + + if (!costBreakdown) { + return null; + } + + const flattenedAggregation = costBreakdown + .map(cost => cost.aggregation) + .flat(); + + const totalCost = aggregationSum(flattenedAggregation); + + const previousPeriodTotal = getPreviousPeriodTotalCost( + flattenedAggregation, + duration, + lastCompleteBillingDate, + ); + const currentPeriodTotal = totalCost - previousPeriodTotal; + const canExpand = costBreakdown.length >= 8; + const otherCategoryIds: string[] = []; + + const breakdownsByDate = costBreakdown.reduce( + (breakdownByDate, breakdown) => { + const breakdownTotal = aggregationSum(breakdown.aggregation); + // Group breakdown items with less than 10% of the total cost into "Other" category if needed + const isOtherCategory = + canExpand && breakdownTotal < totalCost * LOW_COST_THRESHOLD; + + const updatedBreakdownByDate = { ...breakdownByDate }; + if (isOtherCategory) { + otherCategoryIds.push(breakdown.id); + } + breakdown.aggregation.forEach(curAggregation => { + const costsForDate = updatedBreakdownByDate[curAggregation.date] || {}; + + updatedBreakdownByDate[curAggregation.date] = { + ...costsForDate, + [breakdown.id]: + (costsForDate[breakdown.id] || 0) + curAggregation.amount, + }; + }); + + return updatedBreakdownByDate; + }, + {} as Record>, + ); + + const chartData: Record[] = Object.keys(breakdownsByDate).map( + date => { + const costsForDate = Object.keys(breakdownsByDate[date]).reduce( + (dateCosts, breakdown) => { + // Group costs for items that belong to 'Other' in the chart. + const cost = breakdownsByDate[date][breakdown]; + const breakdownCost = + !isExpanded && otherCategoryIds.includes(breakdown) + ? { Other: (dateCosts.Other || 0) + cost } + : { [breakdown]: cost }; + return { ...dateCosts, ...breakdownCost }; + }, + {} as Record, + ); + return { + ...costsForDate, + date: Date.parse(date), + }; + }, + ); + + const sortedBreakdowns = costBreakdown.sort( + (a, b) => aggregationSum(a.aggregation) - aggregationSum(b.aggregation), + ); + + const renderAreas = () => { + const separatedBreakdowns = sortedBreakdowns + // Check that the breakdown is a separate group and hasn't been added to 'Other' + .filter( + breakdown => + breakdown.id !== 'Other' && !otherCategoryIds.includes(breakdown.id), + ) + .map(breakdown => breakdown.id); + // Keep 'Other' category at the bottom of the stack + const breakdownsToDisplay = isExpanded + ? sortedBreakdowns.map(breakdown => breakdown.id) + : ['Other', ...separatedBreakdowns]; + + return breakdownsToDisplay.map((breakdown, i) => { + // Logic to handle case where there are more items than data viz colors. + const color = + theme.palette.dataViz[ + (breakdownsToDisplay.length - 1 - i) % + (theme.palette.dataViz.length - 1) + ]; + return ( + setExpanded(true)} + style={{ + cursor: breakdown === 'Other' && !isExpanded ? 'pointer' : null, + }} + /> + ); + }); + }; + + const tooltipRenderer: ContentRenderer = ({ + label, + payload = [], + }) => { + if (isInvalid({ label, payload })) return null; + + const dateTitle = dayjs(label).utc().format(DEFAULT_DATE_FORMAT); + const items = payload.map(p => ({ + label: p.dataKey as string, + value: formatGraphValue(p.value as number), + fill: p.fill!, + })); + const expandText = ( + + + + + Click to expand + + + ); + return ( + + {items.reverse().map((item, index) => ( + + ))} + {canExpand && !isExpanded ? expandText : null} + + ); + }; + + const options: Partial = { + previousName: formatPeriod(duration, lastCompleteBillingDate, false), + currentName: formatPeriod(duration, lastCompleteBillingDate, true), + hideMarker: true, + }; + + return ( + + + + + + + + + 0, 'dataMax']} + tick={{ fill: classes.axis.fill }} + tickFormatter={formatGraphValue} + width={classes.yAxis.width} + /> + {renderAreas()} + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx deleted file mode 100644 index 0b482bd908..0000000000 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProductChart.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; -import { useTheme, Box } from '@material-ui/core'; -import { - AreaChart, - ContentRenderer, - TooltipProps, - XAxis, - YAxis, - Tooltip as RechartsTooltip, - Area, - ResponsiveContainer, - CartesianGrid, -} from 'recharts'; -import { Cost, DEFAULT_DATE_FORMAT, CostInsightsTheme } from '../../types'; -import { - BarChartTooltip as Tooltip, - BarChartTooltipItem as TooltipItem, - BarChartLegend, -} from '../BarChart'; -import { - overviewGraphTickFormatter, - formatGraphValue, - isInvalid, -} from '../../utils/graphs'; -import { useCostOverviewStyles as useStyles } from '../../utils/styles'; -import { useFilters, useLastCompleteBillingDate } from '../../hooks'; -import { mapFiltersToProps } from './selector'; -import { getPreviousPeriodTotalCost } from '../../utils/change'; -import { formatPeriod } from '../../utils/formatters'; -import { aggregationSum } from '../../utils/sum'; -import { BarChartLegendOptions } from '../BarChart/BarChartLegend'; - -dayjs.extend(utc); - -export type CostOverviewByProductChartProps = { - costsByProduct: Cost[]; -}; - -const LOW_COST_THRESHOLD = 0.1; - -export const CostOverviewByProductChart = ({ - costsByProduct, -}: CostOverviewByProductChartProps) => { - const theme = useTheme(); - const styles = useStyles(theme); - const lastCompleteBillingDate = useLastCompleteBillingDate(); - const { duration } = useFilters(mapFiltersToProps); - - if (!costsByProduct) { - return null; - } - - const flattenedAggregation = costsByProduct - .map(cost => cost.aggregation) - .flat(); - - const totalCost = aggregationSum(flattenedAggregation); - - const previousPeriodTotal = getPreviousPeriodTotalCost( - flattenedAggregation, - duration, - lastCompleteBillingDate, - ); - const currentPeriodTotal = totalCost - previousPeriodTotal; - const productsByDate = costsByProduct.reduce((prodByDate, product) => { - const productTotal = aggregationSum(product.aggregation); - // Group products with less than 10% of the total cost into "Other" category - // when there we have >= 5 products. - const isOtherProduct = - costsByProduct.length >= 5 && - productTotal < totalCost * LOW_COST_THRESHOLD; - const productName = isOtherProduct ? 'Other' : product.id; - const updatedProdByDate = { ...prodByDate }; - - product.aggregation.forEach(curAggregation => { - const productCostsForDate = updatedProdByDate[curAggregation.date] || {}; - - updatedProdByDate[curAggregation.date] = { - ...productCostsForDate, - [productName]: - (productCostsForDate[productName] || 0) + curAggregation.amount, - }; - }); - - return updatedProdByDate; - }, {} as Record>); - - const chartData: Record[] = Object.keys(productsByDate).map( - date => { - return { - ...productsByDate[date], - date: Date.parse(date), - }; - }, - ); - - const renderAreas = () => { - const productGroupNames = new Set( - Object.values(productsByDate) - .map(d => Object.keys(d)) - .flat(), - ); - const sortedProducts = costsByProduct - // Check that product is a separate group and hasn't been added to 'Other' - .filter( - product => product.id !== 'Other' && productGroupNames.has(product.id), - ) - .sort( - (a, b) => aggregationSum(a.aggregation) - aggregationSum(b.aggregation), - ) - .map(product => product.id); - // Keep 'Other' category at the bottom of the stack - return ['Other', ...sortedProducts].map((product, i) => ( - - )); - }; - - const tooltipRenderer: ContentRenderer = ({ - label, - payload = [], - }) => { - if (isInvalid({ label, payload })) return null; - - const title = dayjs(label).utc().format(DEFAULT_DATE_FORMAT); - const items = payload.map(p => ({ - label: p.dataKey as string, - value: formatGraphValue(p.value as number), - fill: p.fill!, - })); - - return ( - - {items.reverse().map((item, index) => ( - - ))} - - ); - }; - - const options: Partial = { - previousName: formatPeriod(duration, lastCompleteBillingDate, false), - currentName: formatPeriod(duration, lastCompleteBillingDate, true), - hideMarker: true, - }; - - return ( - - - - - - - - - 0, 'dataMax']} - tick={{ fill: styles.axis.fill }} - tickFormatter={formatGraphValue} - width={styles.yAxis.width} - /> - {renderAreas()} - - - - - ); -}; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx new file mode 100644 index 0000000000..6fca8f67c9 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { fireEvent } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { CostOverviewCard } from './CostOverviewCard'; +import { Cost } from '../../types'; +import { + changeOf, + getGroupedProducts, + getGroupedProjects, + MockAggregatedDailyCosts, + trendlineOf, +} from '../../utils/mockData'; +import { + MockBillingDateProvider, + MockConfigProvider, + MockFilterProvider, + MockScrollProvider, +} from '../../utils/tests'; +import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider'; + +const mockGroupDailyCost: Cost = { + id: 'test-group', + aggregation: MockAggregatedDailyCosts, + change: changeOf(MockAggregatedDailyCosts), + trendline: trendlineOf(MockAggregatedDailyCosts), +}; + +function renderInContext(children: JSX.Element) { + return renderInTestApp( + + + + + {children} + + + + , + ); +} + +describe('', () => { + it('Renders without exploding', async () => { + const { getByText } = await renderInContext( + , + ); + expect(getByText('Cloud Cost')).toBeInTheDocument(); + }); + + it('Shows breakdown tabs if provided', async () => { + const mockDailyCostWithBreakdowns = { + ...mockGroupDailyCost, + groupedCosts: { + product: getGroupedProducts('R2/P90D/2021-01-01'), + project: getGroupedProjects('R2/P90D/2021-01-01'), + }, + }; + const { getByText } = await renderInContext( + , + ); + expect(getByText('Cloud Cost')).toBeInTheDocument(); + expect(getByText('Breakdown by product')).toBeInTheDocument(); + expect(getByText('Breakdown by project')).toBeInTheDocument(); + + fireEvent.click(getByText('Breakdown by product')); + expect(getByText('Cloud Cost By Product')).toBeInTheDocument(); + + fireEvent.click(getByText('Breakdown by project')); + expect(getByText('Cloud Cost By Project')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index 39adc4bbf1..85a9a9db74 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -14,31 +14,33 @@ * limitations under the License. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, + capitalize, Card, CardContent, Divider, - useTheme, Tab, Tabs, + useTheme, } from '@material-ui/core'; import { CostOverviewChart } from './CostOverviewChart'; -import { CostOverviewByProductChart } from './CostOverviewByProductChart'; +import { CostOverviewBreakdownChart } from './CostOverviewBreakdownChart'; import { CostOverviewHeader } from './CostOverviewHeader'; import { MetricSelect } from '../MetricSelect'; import { PeriodSelect } from '../PeriodSelect'; -import { useScroll, useFilters, useConfig } from '../../hooks'; +import { useConfig, useFilters } from '../../hooks'; import { mapFiltersToProps } from './selector'; import { DefaultNavigation } from '../../utils/navigation'; import { findAlways } from '../../utils/assert'; -import { Cost, CostInsightsTheme, MetricData } from '../../types'; +import { Cost, CostInsightsTheme, Maybe, MetricData } from '../../types'; import { useOverviewTabsStyles } from '../../utils/styles'; +import { ScrollAnchor } from '../../utils/scroll'; export type CostOverviewCardProps = { dailyCostData: Cost; - metricData: MetricData | null; + metricData: Maybe; }; export const CostOverviewCard = ({ @@ -46,27 +48,38 @@ export const CostOverviewCard = ({ metricData, }: CostOverviewCardProps) => { const theme = useTheme(); + const styles = useOverviewTabsStyles(theme); const config = useConfig(); const [tabIndex, setTabIndex] = useState(0); - - const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); const { setDuration, setProject, setMetric, ...filters } = useFilters( mapFiltersToProps, ); + // Reset tabIndex if breakdowns available change + useEffect(() => { + // Intentionally off-by-one to account for the overview tab + const lastIndex = Object.keys(dailyCostData.groupedCosts ?? {}).length; + if (tabIndex > lastIndex) { + setTabIndex(0); + } + }, [dailyCostData, tabIndex, setTabIndex]); + const metric = filters.metric ? findAlways(config.metrics, m => m.kind === filters.metric) : null; - const styles = useOverviewTabsStyles(theme); + const breakdownTabs = Object.keys(dailyCostData.groupedCosts ?? {}).map( + key => ({ + id: key, + label: `Breakdown by ${key}`, + title: `Cloud Cost By ${capitalize(key)}`, + }), + ); const tabs = [ { id: 'overview', label: 'Total cost', title: 'Cloud Cost' }, - { - id: 'breakdown', - label: 'Breakdown by product', - title: 'Cloud Cost By Product', - }, - ]; + ].concat(breakdownTabs); + // tabIndex can temporarily be invalid while the useEffect above processes + const safeTabIndex = tabIndex > tabs.length - 1 ? 0 : tabIndex; const OverviewTabs = () => { return ( @@ -74,7 +87,7 @@ export const CostOverviewCard = ({ setTabIndex(index)} - value={tabIndex} + value={safeTabIndex} > {tabs.map((tab, index) => ( - + {dailyCostData.groupedCosts && } - + - {tabIndex === 0 ? ( + {safeTabIndex === 0 ? ( ) : ( - )} diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx new file mode 100644 index 0000000000..56d636d8aa --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.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 { MigrationBarChartLegend } from './MigrationBarChartLegend'; +import { MigrationBarChart } from './MigrationBarChart'; +import { MigrationData } from '../../alerts'; + +type MigrationAlertProps = { + data: MigrationData; + title: string; + subheader: string; + currentProduct: string; + comparedProduct: string; +}; + +export const MigrationAlertCard = ({ + data, + title, + subheader, + currentProduct, + comparedProduct, +}: MigrationAlertProps) => { + return ( + + + + + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx new file mode 100644 index 0000000000..60aa49b2bc --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.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 { BarChart } from '../BarChart'; +import { + BarChartOptions, + CostInsightsTheme, + Entity, + ResourceData, +} from '../../types'; +import { useTheme } from '@material-ui/core'; + +type MigrationBarChartProps = { + currentProduct: string; + comparedProduct: string; + services: Array; +}; + +export const MigrationBarChart = ({ + currentProduct, + comparedProduct, + services, +}: MigrationBarChartProps) => { + const theme = useTheme(); + + const options: BarChartOptions = { + previousFill: theme.palette.magenta, + currentFill: theme.palette.yellow, + previousName: comparedProduct, + currentName: currentProduct, + }; + + const resources: ResourceData[] = services.map(service => ({ + name: service.id, + previous: service.aggregation[0], + current: service.aggregation[1], + })); + + return ; +}; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx new file mode 100644 index 0000000000..a84e865e28 --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, useTheme } from '@material-ui/core'; +import { CostGrowth } from '../CostGrowth'; +import { LegendItem } from '../LegendItem'; +import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types'; +import { monthOf } from '../../utils/formatters'; + +export type MigrationBarChartLegendProps = { + change: ChangeStatistic; + startDate: string; + currentProduct: string; + comparedProduct: string; +}; + +export const MigrationBarChartLegend = ({ + currentProduct, + comparedProduct, + change, + startDate, +}: MigrationBarChartLegendProps) => { + const theme = useTheme(); + return ( + + + + {currentProduct} + + + + + {comparedProduct} + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/index.ts b/plugins/cost-insights/src/components/MigrationAlertCard/index.ts new file mode 100644 index 0000000000..4feb749c27 --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/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 { MigrationAlertCard } from './MigrationAlertCard'; diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx index 45d0946f86..3719bc8f6c 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.test.tsx @@ -170,7 +170,7 @@ describe('', () => { } it('should render each product panel', async () => { - const noComputeEngineCostsRgx = /There are no Compute Engine costs within this timeframe for your team's projects./; + const noComputeEngineCostsRgx = /There are no Compute Engine costs within this time frame for your team's projects./; const { getByText } = await renderInContext( { - async function getAllProductInsights( - group: string, - project: Maybe, - products: Product[], - lastCompleteBillingDate: string, - ) { + async function getAllProductInsights() { try { dispatchLoadingProducts(true); const responses = await Promise.allSettled( @@ -101,10 +96,10 @@ export const ProductInsights = ({ ), ).then(settledResponseOf); - const initialStates = initialStatesOf(products, responses).sort( + const updatedInitialStates = initialStatesOf(products, responses).sort( totalAggregationSort, ); - setStates(initialStates); + setStates(updatedInitialStates); } catch (e) { setError(e); } finally { @@ -112,7 +107,7 @@ export const ProductInsights = ({ } } - getAllProductInsights(group, project, products, lastCompleteBillingDate); + getAllProductInsights(); }, [ client, group, @@ -125,8 +120,8 @@ export const ProductInsights = ({ useEffect( function handleOnLoaded() { if (onceRef.current) { - const products = initialStates.map(state => state.product); - onLoaded(products); + const initialProducts = initialStates.map(state => state.product); + onLoaded(initialProducts); } else { onceRef.current = true; } diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index fc37bc5dd6..a2f87b1d86 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -77,17 +77,6 @@ const renderProductInsightsCardInTestApp = async ( ); describe('', () => { - it('Renders the scroll anchors', async () => { - const rendered = await renderProductInsightsCardInTestApp( - mockProductCost, - MockComputeEngine, - Duration.P30D, - ); - expect( - rendered.queryByTestId(`scroll-test-compute-engine`), - ).toBeInTheDocument(); - }); - it('Should render the right subheader for products with cost data', async () => { const entity = { ...mockProductCost, @@ -109,7 +98,7 @@ describe('', () => { aggregation: [0, 0], change: { ratio: 0, amount: 0 }, }; - const subheader = `There are no ${MockComputeEngine.name} costs within this timeframe for your team's projects.`; + const subheader = `There are no ${MockComputeEngine.name} costs within this time frame for your team's projects.`; const rendered = await renderProductInsightsCardInTestApp( entity, MockComputeEngine, @@ -117,6 +106,7 @@ describe('', () => { ); const subheaderRgx = new RegExp(subheader); expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); + expect(rendered.queryByText(/sorted by cost/)).not.toBeInTheDocument(); expect( rendered.queryByTestId('.resource-growth-chart-legend'), ).not.toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 534ef490c3..d46737cafb 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -34,9 +34,9 @@ import { MapLoadingToProps, useLastCompleteBillingDate, useLoading, - useScroll, } from '../../hooks'; import { findAnyKey } from '../../utils/assert'; +import { ScrollAnchor } from '../../utils/scroll'; type LoadingProps = (isLoading: boolean) => void; @@ -60,7 +60,6 @@ export const ProductInsightsCard = ({ }: PropsWithChildren) => { const classes = useStyles(); const mountedRef = useRef(false); - const { ScrollAnchor } = useScroll(product.kind); const [error, setError] = useState>(null); const dispatchLoading = useLoading(mapLoadingToProps); const lastCompleteBillingDate = useLastCompleteBillingDate(); @@ -96,9 +95,10 @@ export const ProductInsightsCard = ({ const entityKey = findAnyKey(entity?.entities); const entities = entityKey ? entity!.entities[entityKey] : []; - const subheader = entityKey - ? `${pluralize(entityKey, entities.length, true)}, sorted by cost` - : null; + const subheader = + entityKey && entities.length + ? `${pluralize(entityKey, entities.length, true)}, sorted by cost` + : null; const headerProps = { classes: classes, action: , @@ -107,7 +107,7 @@ export const ProductInsightsCard = ({ if (error || !entity) { return ( - + {error ? error.message @@ -123,7 +123,7 @@ export const ProductInsightsCard = ({ subheader={subheader} headerProps={headerProps} > - + {entities.length ? ( ) : ( - There are no {product.name} costs within this timeframe for your + There are no {product.name} costs within this time frame for your team's projects. )} diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx index 02b66b0fd1..777ea26822 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -28,7 +28,7 @@ import { Product, ProjectGrowthData, } from '../../types'; -import { ProjectGrowthAlert } from '../../utils/alerts'; +import { ProjectGrowthAlert } from '../../alerts'; const today = moment().format(DEFAULT_DATE_FORMAT); diff --git a/plugins/cost-insights/src/forms/AlertAcceptForm.tsx b/plugins/cost-insights/src/forms/AlertAcceptForm.tsx new file mode 100644 index 0000000000..47ea93c601 --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertAcceptForm.tsx @@ -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 React, { + useState, + forwardRef, + ChangeEvent, + FormEventHandler, +} from 'react'; +import { Checkbox, FormControl, FormControlLabel } from '@material-ui/core'; +import { Alert, AlertFormProps } from '../types'; + +export type AlertAcceptFormProps = AlertFormProps; + +export const AlertAcceptForm = forwardRef< + HTMLFormElement, + AlertAcceptFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const [checked, setChecked] = useState(false); + + const onFormSubmit: FormEventHandler = e => { + e.preventDefault(); + onSubmit(null); + }; + + const onChecked = (_: ChangeEvent, isChecked: boolean) => { + setChecked(isChecked); + disableSubmit(!isChecked); + }; + + return ( + + + + } + /> + + + ); +}); diff --git a/plugins/cost-insights/src/forms/AlertDismissForm.tsx b/plugins/cost-insights/src/forms/AlertDismissForm.tsx new file mode 100644 index 0000000000..4271785958 --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertDismissForm.tsx @@ -0,0 +1,152 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, { + ChangeEvent, + useEffect, + useState, + forwardRef, + FormEventHandler, +} from 'react'; +import { + Box, + Collapse, + FormControl, + FormControlLabel, + TextField, + Typography, + Radio, + RadioGroup, +} from '@material-ui/core'; +import { + Alert, + AlertFormProps, + AlertDismissReason, + AlertDismissOptions, + AlertDismissFormData, + Maybe, +} from '../types'; +import { useAlertDialogStyles as useStyles } from '../utils/styles'; + +export type AlertDismissFormProps = AlertFormProps; + +export const AlertDismissForm = forwardRef< + HTMLFormElement, + AlertDismissFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const classes = useStyles(); + const [other, setOther] = useState>(null); + const [feedback, setFeedback] = useState>(null); + const [reason, setReason] = useState( + AlertDismissReason.Resolved, + ); + + const onFormSubmit: FormEventHandler = e => { + e.preventDefault(); + if (reason) { + onSubmit({ + other: other, + reason: reason, + feedback: feedback, + }); + } + }; + + const onReasonChange = (_: ChangeEvent, value: string) => { + setReason(value as AlertDismissReason); + }; + + const onOtherChange = (e: ChangeEvent) => { + return e.target.value + ? setOther(e.target.value as AlertDismissReason) + : setOther(null); + }; + + const onFeedbackChange = (e: ChangeEvent) => { + return e.target.value + ? setFeedback(e.target.value as AlertDismissReason) + : setFeedback(null); + }; + + useEffect(() => { + function validateDismissForm() { + if (reason === AlertDismissReason.Other) { + if (other) { + disableSubmit(false); + } else { + disableSubmit(true); + } + } else if (reason) { + disableSubmit(false); + } else { + disableSubmit(true); + } + } + + validateDismissForm(); + }, [reason, other, disableSubmit]); + + return ( +
+ + + Reason for dismissing? + + + + {AlertDismissOptions.map(option => ( + } + /> + ))} + + + + + + + + + Any other feedback you can provide? + + + + + ); +}); diff --git a/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx new file mode 100644 index 0000000000..743b5ddac1 --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + ChangeEvent, + useEffect, + useState, + forwardRef, + FormEventHandler, +} from 'react'; +import dayjs from 'dayjs'; +import { + Box, + FormControl, + FormControlLabel, + RadioGroup, + Radio, + Typography, +} from '@material-ui/core'; +import { + Alert, + AlertFormProps, + Duration, + DEFAULT_DATE_FORMAT, + Maybe, + AlertSnoozeFormData, + AlertSnoozeOptions, +} from '../types'; +import { useAlertDialogStyles as useStyles } from '../utils/styles'; +import { intervalsOf } from '../utils/duration'; + +export type AlertSnoozeFormProps = AlertFormProps; + +export const AlertSnoozeForm = forwardRef< + HTMLFormElement, + AlertSnoozeFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const classes = useStyles(); + const [duration, setDuration] = useState>(Duration.P7D); + + useEffect(() => disableSubmit(false), [disableSubmit]); + + const onFormSubmit: FormEventHandler = e => { + e.preventDefault(); + if (duration) { + const repeatInterval = 1; + const today = dayjs().format(DEFAULT_DATE_FORMAT); + onSubmit({ + intervals: intervalsOf(duration, today, repeatInterval), + }); + } + }; + + const onSnoozeDurationChange = ( + _: ChangeEvent, + value: string, + ) => { + setDuration(value as Duration); + }; + + return ( +
+ + + For how long? + + + + {AlertSnoozeOptions.map(option => ( + } + /> + ))} + + + + + ); +}); diff --git a/plugins/cost-insights/src/forms/MigrationDismissForm.tsx b/plugins/cost-insights/src/forms/MigrationDismissForm.tsx new file mode 100644 index 0000000000..ce71af3a37 --- /dev/null +++ b/plugins/cost-insights/src/forms/MigrationDismissForm.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, + forwardRef, + FormEventHandler, + ChangeEvent, +} from 'react'; +import { + Checkbox, + FormControl, + FormControlLabel, + FormGroup, + Typography, +} from '@material-ui/core'; +import { AlertFormProps, Entity } from '../types'; +import { MigrationAlert } from '../alerts'; +import { findAlways } from '../utils/assert'; + +export type MigrationDismissFormData = { + services: Entity[]; +}; + +export type MigrationDismissFormProps = AlertFormProps< + MigrationAlert, + MigrationDismissFormData +>; + +export const MigrationDismissForm = forwardRef< + HTMLFormElement, + MigrationDismissFormProps +>(({ onSubmit, disableSubmit, alert }, ref) => { + const [services, setServices] = useState(alert.data.services); + + const onFormSubmit: FormEventHandler = e => { + /* Remember to prevent default form behavior */ + e.preventDefault(); + onSubmit({ services: services }); + }; + + const onCheckboxChange = ( + e: ChangeEvent, + checked: boolean, + ) => { + if (checked) { + const service = findAlways( + alert.data.services, + s => s.id === e.target.value, + ); + setServices(prevServices => prevServices.concat(service)); + } else { + setServices(prevServices => + prevServices.filter(p => p.id !== e.target.value), + ); + } + }; + + /* Submit button is disabled by default. Use props.disableSubmit to toggle disabled state. */ + useEffect(() => { + if (services.length) { + disableSubmit(false); + } else { + disableSubmit(true); + } + }, [services, disableSubmit]); + + return ( + /* All custom forms must accept a ref and implement an onSubmit handler. */ +
+ + + Or choose which services to dismiss this alert for. + + + {alert.data.services.map((service, index) => ( + p.id === service.id)} + onChange={onCheckboxChange} + /> + } + /> + ))} + + + + ); +}); diff --git a/plugins/cost-insights/src/forms/index.ts b/plugins/cost-insights/src/forms/index.ts new file mode 100644 index 0000000000..5b8384a293 --- /dev/null +++ b/plugins/cost-insights/src/forms/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { AlertAcceptForm } from './AlertAcceptForm'; +export { AlertSnoozeForm } from './AlertSnoozeForm'; +export { AlertDismissForm } from './AlertDismissForm'; +export { MigrationDismissForm } from './MigrationDismissForm'; +export type { MigrationDismissFormData } from './MigrationDismissForm'; diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx index 0a1abbdad3..137762cc2d 100644 --- a/plugins/cost-insights/src/hooks/useScroll.tsx +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -18,105 +18,44 @@ import React, { SetStateAction, useState, useContext, - useEffect, - useRef, PropsWithChildren, } from 'react'; -import { CSSProperties } from '@material-ui/styles'; import { Maybe } from '../types'; export type ScrollTo = Maybe; export type ScrollContextProps = { - scrollTo: ScrollTo; - setScrollTo: Dispatch>; + scroll: ScrollTo; + setScroll: Dispatch>; }; -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 }: PropsWithChildren<{}>) => { - const [scrollTo, setScrollTo] = useState(null); - + const [scroll, setScroll] = useState(null); return ( - + {children} ); }; -export function useScroll(id: ScrollTo): ScrollUtils { +export enum ScrollType { + AlertSummary = 'alert-status-summary', +} + +export function useScroll() { const context = useContext(ScrollContext); if (!context) { assertNever(); } - return { - ScrollAnchor: props => , - scrollIntoView: () => context.setScrollTo(id), - }; + return [context.scroll, context.setScroll] as const; } function assertNever(): never { - throw new Error( - `Cannot use useScroll or ScrollAnchor outside ScrollProvider`, - ); + throw new Error(`Cannot use useScroll outside ScrollProvider`); } diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 7cd0456ad1..13ce929401 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -17,8 +17,8 @@ export { plugin } from './plugin'; export * from './client'; export * from './api'; +export { ProjectGrowthAlert, UnlabeledDataflowAlert } from './alerts'; export * from './components'; export { useCurrency } from './hooks'; export * from './types'; export * from './utils/tests'; -export * from './utils/alerts'; diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index 18b86c8cb8..84c37a7c15 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -13,23 +13,147 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { ForwardRefExoticComponent, RefAttributes } from 'react'; import { ChangeStatistic } from './ChangeStatistic'; +import { Duration } from './Duration'; import { Maybe } from './Maybe'; /** * Generic alert type with required fields for display. The `element` field will be rendered in * the Cost Insights "Action Items" section. This should use data fetched in the CostInsightsApi * implementation to render an InfoCard or other visualization. + * + * The alert type exposes hooks which can be used to enable and access various events, + * such as when a user dismisses or snoozes an alert. Default forms and buttons + * will be rendered if a hook is defined. + * + * Each default form can be overridden with a custom component. It must be implemented using + * React.forwardRef. See https://reactjs.org/docs/forwarding-refs + * + * Errors thrown within hooks will generate a snackbar error notification. */ + export type Alert = { title: string; subtitle: string; + element?: JSX.Element; + status?: AlertStatus; url?: string; buttonText?: string; // Default: View Instructions - element: JSX.Element; + SnoozeForm?: Maybe; + AcceptForm?: Maybe; + DismissForm?: Maybe; + onSnoozed?(options: AlertOptions): Promise; + onAccepted?(options: AlertOptions): Promise; + onDismissed?(options: AlertOptions): Promise; }; +export type AlertForm< + A extends Alert = any, + Data = any +> = ForwardRefExoticComponent< + AlertFormProps & RefAttributes +>; + +export interface AlertOptions { + data: T; + group: string; +} + +/** + * Default snooze form intervals are expressed using an ISO 8601 repeating interval string. + * For example, R1/P7D/2020-09-02 for 1 week or R1/P30D/2020-09-02 for 1 month. + * + * For example, if a user dismisses an alert on Monday January 01 for 1 week, + * it can be re-served on Monday, January 08. 7 calendar days from January 02, + * inclusive of the last day. + * + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ +export interface AlertSnoozeFormData { + intervals: string; +} + +export interface AlertDismissFormData { + other: Maybe; + reason: AlertDismissReason; + feedback: Maybe; +} + +// TODO: Convert enum to literal +export enum AlertStatus { + Snoozed = 'snoozed', + Accepted = 'accepted', + Dismissed = 'dismissed', +} + +export type AlertFormProps = { + alert: A; + onSubmit: (data: FormData) => void; + disableSubmit: (isDisabled: boolean) => void; +}; + +export interface AlertDismissOption { + label: string; + reason: string; +} + +export enum AlertDismissReason { + Other = 'other', + Resolved = 'resolved', + Expected = 'expected', + Seasonal = 'seasonal', + Migration = 'migration', + NotApplicable = 'not-applicable', +} + +export const AlertDismissOptions: AlertDismissOption[] = [ + { + reason: AlertDismissReason.Resolved, + label: 'This action item is now resolved.', + }, + { + reason: AlertDismissReason.Seasonal, + label: 'This is an expected increase at this time of year.', + }, + { + reason: AlertDismissReason.Migration, + label: 'This increase is from a migration in process.', + }, + { + reason: AlertDismissReason.Expected, + label: 'This is an expected increase due to our team’s priorities.', + }, + { + reason: AlertDismissReason.NotApplicable, + label: 'This action item doesn’t make sense for my team.', + }, + { + reason: AlertDismissReason.Other, + label: 'Other (please specify)', + }, +]; + +export type AlertSnoozeOption = { + label: string; + duration: Duration; +}; + +export const AlertSnoozeOptions: AlertSnoozeOption[] = [ + { + duration: Duration.P7D, + label: '1 Week', + }, + { + duration: Duration.P30D, + label: '1 Month', + }, + { + duration: Duration.P90D, + label: '1 Quarter', + }, +]; + export interface AlertCost { id: string; aggregation: [number, number]; diff --git a/plugins/cost-insights/src/types/Cost.ts b/plugins/cost-insights/src/types/Cost.ts index dd28bba6b0..c3b63946af 100644 --- a/plugins/cost-insights/src/types/Cost.ts +++ b/plugins/cost-insights/src/types/Cost.ts @@ -23,5 +23,5 @@ export interface Cost { aggregation: DateAggregation[]; change?: ChangeStatistic; trendline?: Trendline; - groupedCosts?: Cost[]; + groupedCosts?: Record; } diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index c0f03d5c27..e25f38e63d 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -21,6 +21,7 @@ * September 15. */ export enum Duration { + P7D = 'P7D', P30D = 'P30D', P90D = 'P90D', P3M = 'P3M', diff --git a/plugins/cost-insights/src/utils/alerts.test.tsx b/plugins/cost-insights/src/utils/alerts.test.tsx new file mode 100644 index 0000000000..d821fd9f1f --- /dev/null +++ b/plugins/cost-insights/src/utils/alerts.test.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { formOf } from './alerts'; +import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms'; +import { Alert, AlertStatus, AlertFormProps } from '../types'; + +type Props = AlertFormProps; + +const createMockForm = (children: ReactNode) => + React.forwardRef((props, ref) => ( +
+ {children} + + )); + +const snoozeDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), +}; + +const snoozeCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: createMockForm('Snooze'), +}; + +const snoozeNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: null, +}; + +const acceptDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), +}; + +const acceptCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), + AcceptForm: createMockForm('Accept'), +}; + +const acceptNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), + AcceptForm: null, +}; + +const dismissDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), +}; + +const dismissCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: createMockForm('Dismiss'), +}; + +const dismissNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: null, +}; + +describe('formOf', () => { + describe.each` + msg | alert | status | expected + ${'default snooze form'} | ${snoozeDefault} | ${AlertStatus.Snoozed} | ${AlertSnoozeForm} + ${'custom snooze form'} | ${snoozeCustom} | ${AlertStatus.Snoozed} | ${snoozeCustom.SnoozeForm} + ${'null snooze form'} | ${snoozeNull} | ${AlertStatus.Snoozed} | ${null} + ${'default accept form'} | ${acceptDefault} | ${AlertStatus.Accepted} | ${AlertAcceptForm} + ${'custom accept form'} | ${acceptCustom} | ${AlertStatus.Accepted} | ${acceptCustom.AcceptForm} + ${'null accept form'} | ${acceptNull} | ${AlertStatus.Accepted} | ${null} + ${'default dismiss form'} | ${dismissDefault} | ${AlertStatus.Dismissed} | ${AlertDismissForm} + ${'custom dismiss form'} | ${dismissCustom} | ${AlertStatus.Dismissed} | ${dismissCustom.DismissForm} + ${'null dismiss form'} | ${dismissNull} | ${AlertStatus.Dismissed} | ${null} + ${'no form or status'} | ${null} | ${null} | ${null} + `('Should render the correct form', ({ msg, alert, status, expected }) => { + it(`for ${msg}`, () => { + const result = formOf(alert, status); + expect(result).toBe(expected); + }); + }); +}); diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx index fbb148f38a..56403a664c 100644 --- a/plugins/cost-insights/src/utils/alerts.tsx +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -14,50 +14,118 @@ * limitations under the License. */ -import React from 'react'; -import { Alert, UnlabeledDataflowData, ProjectGrowthData } from '../types'; -import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; -import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard'; +import { Alert, AlertForm, AlertStatus, Maybe } from '../types'; +import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms'; + +const createAlertHandler = (status?: AlertStatus) => (alert: Alert) => + alert.status === status; +export const isAlertActive = (alert: Alert) => !hasProperty(alert, 'status'); +export const isAlertSnoozed = createAlertHandler(AlertStatus.Snoozed); +export const isAlertAccepted = createAlertHandler(AlertStatus.Accepted); +export const isAlertDismissed = createAlertHandler(AlertStatus.Dismissed); + +const createStatusHandler = (status: AlertStatus) => (s: Maybe) => + s === status; +export const isStatusSnoozed = createStatusHandler(AlertStatus.Snoozed); +export const isStatusAccepted = createStatusHandler(AlertStatus.Accepted); +export const isStatusDismissed = createStatusHandler(AlertStatus.Dismissed); + +const createAlertEventHandler = ( + onEvent: 'onSnoozed' | 'onAccepted' | 'onDismissed', +) => (alert: Maybe): boolean => hasProperty(alert, onEvent); +export const isSnoozeEnabled = createAlertEventHandler('onSnoozed'); +export const isAcceptEnabled = createAlertEventHandler('onAccepted'); +export const isDismissEnabled = createAlertEventHandler('onDismissed'); + +const createFormEnabledHandler = ( + Form: 'SnoozeForm' | 'AcceptForm' | 'DismissForm', +) => (alert: Maybe): boolean => { + if (!alert) return false; + if (alert[Form] === null) return false; + switch (Form) { + case 'SnoozeForm': + return isSnoozeEnabled(alert); + case 'AcceptForm': + return isAcceptEnabled(alert); + case 'DismissForm': + return isDismissEnabled(alert); + default: + return false; + } +}; +export const isSnoozeFormEnabled = createFormEnabledHandler('SnoozeForm'); +export const isAcceptFormEnabled = createFormEnabledHandler('AcceptForm'); +export const isDismissFormEnabled = createFormEnabledHandler('DismissForm'); /** - * The alerts below are examples of Alert implementation; the CostInsightsApi permits returning - * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi - * fetches alert data from the backend, then creates Alert classes with the data. + * Utility for determining if a form is disabled. + * When a form is disabled, the dialog button's type should convert from submit to button. + * @param alert + * @param status */ - -export class UnlabeledDataflowAlert implements Alert { - data: UnlabeledDataflowData; - - constructor(data: UnlabeledDataflowData) { - this.data = data; +export const isFormDisabled = ( + alert: Maybe, + status: Maybe, +): boolean => { + switch (status) { + case AlertStatus.Snoozed: + return alert?.SnoozeForm === null; + case AlertStatus.Accepted: + return alert?.AcceptForm === null; + case AlertStatus.Dismissed: + return alert?.DismissForm === null; + default: + return false; } +}; - title = 'Add labels to workflows'; - subtitle = - 'Labels show in billing data, enabling cost insights for each workflow.'; - url = '/cost-insights/labeling-jobs'; - - get element() { - return ; +export function formOf( + alert: Maybe, + status: Maybe, +): Maybe { + switch (status) { + case AlertStatus.Snoozed: { + const SnoozeForm = alert?.SnoozeForm ?? AlertSnoozeForm; + return isSnoozeFormEnabled(alert) ? SnoozeForm : null; + } + case AlertStatus.Accepted: { + const AcceptForm = alert?.AcceptForm ?? AlertAcceptForm; + return isAcceptFormEnabled(alert) ? AcceptForm : null; + } + case AlertStatus.Dismissed: { + const DismissForm = alert?.DismissForm ?? AlertDismissForm; + return isDismissFormEnabled(alert) ? DismissForm : null; + } + default: + return null; } } -export class ProjectGrowthAlert implements Alert { - data: ProjectGrowthData; - - constructor(data: ProjectGrowthData) { - this.data = data; - } - - get title() { - return `Investigate cost growth in project ${this.data.project}`; - } - - subtitle = - 'Cost growth outpacing business growth is unsustainable long-term.'; - url = '/cost-insights/investigating-growth'; - - get element() { - return ; +/** + * Utility for choosing from a fixed set of values for a given alert status. + * @param status + * @param values + */ +export function choose( + status: Maybe, + values: [T, T, T], + none: T, +): T { + switch (status) { + case AlertStatus.Snoozed: + return values[0]; + case AlertStatus.Accepted: + return values[1]; + case AlertStatus.Dismissed: + return values[2]; + default: + return none; } } + +export function hasProperty(alert: Maybe, prop: keyof Alert): boolean { + return prop in (alert ?? {}); +} + +export const sumOfAllAlerts = (sum: number, alerts: Alert[]) => + sum + alerts.length; diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts index 7cc03caa0b..9516dfe270 100644 --- a/plugins/cost-insights/src/utils/change.test.ts +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -76,13 +76,13 @@ describe('getPreviousPeriodTotalCost', () => { change: changeOf(MockAggregatedDailyCosts), trendline: trendlineOf(MockAggregatedDailyCosts), }; - const exclusiveEndDate = '2020-09-30'; + const inclusiveEndDate = '2020-09-30'; expect( getPreviousPeriodTotalCost( mockGroupDailyCost.aggregation, Duration.P30D, - exclusiveEndDate, + inclusiveEndDate, ), - ).toEqual(100_000); + ).toEqual(96_600); }); }); diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts index fc50439aad..950fbfeb82 100644 --- a/plugins/cost-insights/src/utils/change.ts +++ b/plugins/cost-insights/src/utils/change.ts @@ -22,14 +22,13 @@ import { GrowthType, MetricData, Duration, - DEFAULT_DATE_FORMAT, DateAggregation, } from '../types'; import dayjs, { OpUnitType } from 'dayjs'; -import duration from 'dayjs/plugin/duration'; +import durationPlugin from 'dayjs/plugin/duration'; import { inclusiveStartDateOf } from './duration'; -dayjs.extend(duration); +dayjs.extend(durationPlugin); // Used for displaying status colors export function growthOf(ratio: number, amount?: number) { @@ -73,10 +72,7 @@ export function getPreviousPeriodTotalCost( inclusiveEndDate: string, ): number { const dayjsDuration = dayjs.duration(duration); - const startDate = inclusiveStartDateOf( - duration, - dayjs(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT), - ); + const startDate = inclusiveStartDateOf(duration, inclusiveEndDate); // dayjs doesn't allow adding an ISO 8601 period to dates. const [amount, type]: [number, OpUnitType] = dayjsDuration.days() ? [dayjsDuration.days(), 'day'] diff --git a/plugins/cost-insights/src/utils/currency.ts b/plugins/cost-insights/src/utils/currency.ts index f1d67a14e4..8adbc4dcaa 100644 --- a/plugins/cost-insights/src/utils/currency.ts +++ b/plugins/cost-insights/src/utils/currency.ts @@ -20,6 +20,7 @@ export const rateOf = (cost: number, duration: Duration) => { switch (duration) { case Duration.P30D: return cost / 12; + case Duration.P7D: case Duration.P90D: case Duration.P3M: return cost / 4; diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 6eebead1d7..27447537cc 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -24,22 +24,21 @@ export const DEFAULT_DURATION = Duration.P30D; * Derive the start date of a given period, assuming two repeating intervals. * * @param duration see comment on Duration enum - * @param exclusiveEndDate from CostInsightsApi.getLastCompleteBillingDate + 1 day + * @param inclusiveEndDate from CostInsightsApi.getLastCompleteBillingDate */ export function inclusiveStartDateOf( duration: Duration, - exclusiveEndDate: string, + inclusiveEndDate: string, ): string { switch (duration) { + case Duration.P7D: case Duration.P30D: case Duration.P90D: - return moment(exclusiveEndDate) - .utc() + return moment(inclusiveEndDate) .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment(exclusiveEndDate) - .utc() + return moment(inclusiveEndDate) .startOf('quarter') .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); @@ -53,15 +52,12 @@ export function exclusiveEndDateOf( inclusiveEndDate: string, ): string { switch (duration) { + case Duration.P7D: case Duration.P30D: case Duration.P90D: - return moment(inclusiveEndDate) - .utc() - .add(1, 'day') - .format(DEFAULT_DATE_FORMAT); + return moment(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT); case Duration.P3M: return moment(quarterEndDate(inclusiveEndDate)) - .utc() .add(1, 'day') .format(DEFAULT_DATE_FORMAT); default: @@ -74,18 +70,24 @@ export function inclusiveEndDateOf( inclusiveEndDate: string, ): string { return moment(exclusiveEndDateOf(duration, inclusiveEndDate)) - .utc() .subtract(1, 'day') .format(DEFAULT_DATE_FORMAT); } // https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals -export function intervalsOf(duration: Duration, inclusiveEndDate: string) { - return `R2/${duration}/${exclusiveEndDateOf(duration, inclusiveEndDate)}`; +export function intervalsOf( + duration: Duration, + inclusiveEndDate: string, + repeating: number = 2, +) { + return `R${repeating}/${duration}/${exclusiveEndDateOf( + duration, + inclusiveEndDate, + )}`; } export function quarterEndDate(inclusiveEndDate: string): string { - const endDate = moment(inclusiveEndDate).utc(); + const endDate = moment(inclusiveEndDate); const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT); if (endOfQuarter === inclusiveEndDate) { return endDate.format(DEFAULT_DATE_FORMAT); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index 182c567644..8f2b94f997 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -16,7 +16,7 @@ import moment from 'moment'; import pluralize from 'pluralize'; -import { Duration, DEFAULT_DATE_FORMAT } from '../types'; +import { Duration } from '../types'; import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration'; export type Period = { @@ -92,11 +92,8 @@ export function formatPercent(n: number): string { } export function formatLastTwoLookaheadQuarters(inclusiveEndDate: string) { - const exclusiveEndDate = moment(inclusiveEndDate) - .add(1, 'day') - .format(DEFAULT_DATE_FORMAT); const start = moment( - inclusiveStartDateOf(Duration.P3M, exclusiveEndDate), + inclusiveStartDateOf(Duration.P3M, inclusiveEndDate), ).format('[Q]Q YYYY'); const end = moment(inclusiveEndDateOf(Duration.P3M, inclusiveEndDate)).format( '[Q]Q YYYY', diff --git a/plugins/cost-insights/src/utils/loading.ts b/plugins/cost-insights/src/utils/loading.ts index 3328f23cfc..d15101075d 100644 --- a/plugins/cost-insights/src/utils/loading.ts +++ b/plugins/cost-insights/src/utils/loading.ts @@ -29,6 +29,7 @@ export enum DefaultLoadingAction { CostInsightsInitial = 'cost-insights-initial', CostInsightsPage = 'cost-insights-page', CostInsightsProducts = 'cost-insights-products', + CostInsightsAlerts = 'cost-insights-alerts', } export const INITIAL_LOADING_ACTIONS = [ diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index 19f9676eea..d4ac4cd2df 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -36,7 +36,7 @@ import { getDefaultState as getDefaultLoadingState, } from '../utils/loading'; import { findAlways } from '../utils/assert'; -import { inclusiveStartDateOf } from './duration'; +import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; type mockAlertRenderer = (alert: T) => T; type mockEntityRenderer = (entity: T) => T; @@ -211,16 +211,15 @@ export function trendlineOf(aggregation: DateAggregation[]): Trendline { } export function changeOf(aggregation: DateAggregation[]): ChangeStatistic { - const half = Math.ceil(aggregation.length / 2); - const before = aggregation - .slice(0, half) - .reduce((sum, a) => sum + a.amount, 0); - const after = aggregation - .slice(half, aggregation.length) - .reduce((sum, a) => sum + a.amount, 0); + const firstAmount = aggregation.length ? aggregation[0].amount : 0; + const lastAmount = aggregation.length + ? aggregation[aggregation.length - 1].amount + : 0; + const ratio = + firstAmount !== 0 ? (lastAmount - firstAmount) / firstAmount : 0; return { - ratio: (after - before) / before, - amount: after - before, + ratio: ratio, + amount: lastAmount - firstAmount, }; } @@ -229,8 +228,9 @@ export function aggregationFor( baseline: number, ): DateAggregation[] { const { duration, endDate } = parseIntervals(intervals); + const inclusiveEndDate = inclusiveEndDateOf(duration, endDate); const days = dayjs(endDate).diff( - inclusiveStartDateOf(duration, endDate), + inclusiveStartDateOf(duration, inclusiveEndDate), 'day', ); @@ -245,7 +245,7 @@ export function aggregationFor( return [...Array(days).keys()].reduce( (values: DateAggregation[], i: number): DateAggregation[] => { const last = values.length ? values[values.length - 1].amount : baseline; - const date = dayjs(inclusiveStartDateOf(duration, endDate)) + const date = dayjs(inclusiveStartDateOf(duration, inclusiveEndDate)) .add(i, 'day') .format(DEFAULT_DATE_FORMAT); const amount = Math.max(0, last + nextDelta()); @@ -530,7 +530,7 @@ export const SampleBigQueryInsights: Entity = { entities: { dataset: [ { - id: 'entity-a', + id: 'dataset-a', aggregation: [5_000, 10_000], change: { ratio: 1, @@ -539,7 +539,7 @@ export const SampleBigQueryInsights: Entity = { entities: {}, }, { - id: 'entity-b', + id: 'dataset-b', aggregation: [5_000, 10_000], change: { ratio: 1, @@ -548,7 +548,7 @@ export const SampleBigQueryInsights: Entity = { entities: {}, }, { - id: 'entity-c', + id: 'dataset-c', aggregation: [0, 10_000], change: { ratio: 10_000, @@ -600,7 +600,7 @@ export const SampleCloudDataflowInsights: Entity = { }, }, { - id: 'entity-a', + id: 'pipeline-a', aggregation: [60_000, 70_000], change: { ratio: 0.16666666666666666, @@ -639,7 +639,7 @@ export const SampleCloudDataflowInsights: Entity = { }, }, { - id: 'entity-b', + id: 'pipeline-b', aggregation: [12_000, 8_000], change: { ratio: -0.33333, @@ -669,7 +669,7 @@ export const SampleCloudDataflowInsights: Entity = { }, }, { - id: 'entity-c', + id: 'pipeline-c', aggregation: [0, 10_000], change: { ratio: 10_000, @@ -691,7 +691,7 @@ export const SampleCloudStorageInsights: Entity = { entities: { bucket: [ { - id: 'entity-a', + id: 'bucket-a', aggregation: [15_000, 20_000], change: { ratio: 0.333, @@ -730,7 +730,7 @@ export const SampleCloudStorageInsights: Entity = { }, }, { - id: 'entity-b', + id: 'bucket-b', aggregation: [30_000, 25_000], change: { ratio: -0.16666, @@ -769,7 +769,7 @@ export const SampleCloudStorageInsights: Entity = { }, }, { - id: 'entity-c', + id: 'bucket-c', aggregation: [0, 0], change: { ratio: 0, @@ -791,7 +791,7 @@ export const SampleComputeEngineInsights: Entity = { entities: { service: [ { - id: 'entity-a', + id: 'service-a', aggregation: [20_000, 10_000], change: { ratio: -0.5, @@ -850,7 +850,7 @@ export const SampleComputeEngineInsights: Entity = { }, }, { - id: 'entity-b', + id: 'service-b', aggregation: [10_000, 20_000], change: { ratio: 1, @@ -909,7 +909,7 @@ export const SampleComputeEngineInsights: Entity = { }, }, { - id: 'entity-c', + id: 'service-c', aggregation: [0, 10_000], change: { ratio: 10_000, @@ -931,7 +931,7 @@ export const SampleEventsInsights: Entity = { entities: { event: [ { - id: 'entity-a', + id: 'event-a', aggregation: [15_000, 7_000], change: { ratio: -0.53333333333, @@ -970,7 +970,7 @@ export const SampleEventsInsights: Entity = { }, }, { - id: 'entity-b', + id: 'event-b', aggregation: [5_000, 3_000], change: { ratio: -0.4, @@ -1065,3 +1065,18 @@ export const getGroupedProducts = (intervals: string) => [ aggregation: aggregationFor(intervals, 250), }, ]; + +export const getGroupedProjects = (intervals: string) => [ + { + id: 'project-a', + aggregation: aggregationFor(intervals, 1_700), + }, + { + id: 'project-b', + aggregation: aggregationFor(intervals, 350), + }, + { + id: 'project-c', + aggregation: aggregationFor(intervals, 1_300), + }, +]; diff --git a/plugins/cost-insights/src/utils/scroll.tsx b/plugins/cost-insights/src/utils/scroll.tsx new file mode 100644 index 0000000000..06f01775df --- /dev/null +++ b/plugins/cost-insights/src/utils/scroll.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, useRef } from 'react'; +import { ScrollTo, useScroll } from '../hooks/useScroll'; + +/* + Utility component use in conjuction with useScroll that allows scrollable components to control behavior and offset. + 1. ScrollAnchor must be a direct child of a scrollable component. + 2. ScrollAnchor's parent position must be relative. + 3. ScrollAnchor's id must be unique. +*/ + +export interface ScrollAnchorProps extends ScrollIntoViewOptions { + id: ScrollTo; + top?: number; + left?: number; +} + +export const ScrollAnchor = ({ + id, + left = 0, + top = -20, + block = 'start', + inline = 'nearest', + behavior = 'smooth', +}: ScrollAnchorProps) => { + const divRef = useRef(null); + const [scroll, setScroll] = useScroll(); + + useEffect(() => { + function scrollIntoView() { + if (divRef.current && scroll === id) { + divRef.current.scrollIntoView({ + block, + inline, + behavior, + }); + setScroll(null); + } + } + + scrollIntoView(); + }, [scroll, setScroll, id, behavior, block, inline]); + + return ( +
+ ); +}; diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts index c04ceba8e3..acb900c490 100644 --- a/plugins/cost-insights/src/utils/styles.ts +++ b/plugins/cost-insights/src/utils/styles.ts @@ -479,8 +479,8 @@ export const useSelectStyles = makeStyles( }), ); -export const useAlertActionCardStyles = makeStyles( - (theme: BackstageTheme) => +export const useActionItemCardStyles = makeStyles( + (theme: CostInsightsTheme) => createStyles({ card: { boxShadow: 'none', @@ -489,15 +489,12 @@ export const useAlertActionCardStyles = makeStyles( backgroundColor: theme.palette.textVerySubtle, color: theme.palette.text.primary, }, - }), -); - -export const useAlertActionCardHeader = makeStyles( - (theme: CostInsightsTheme) => - createStyles({ root: { + minHeight: 80, paddingBottom: theme.spacing(2), borderRadius: theme.shape.borderRadius, + }, + activeRoot: { cursor: 'pointer', transition: theme.transitions.create('background', { duration: theme.transitions.duration.short, @@ -599,3 +596,31 @@ export const useEntityDialogStyles = makeStyles(theme => }, }), ); + +export const useAlertDialogStyles = makeStyles((theme: BackstageTheme) => + createStyles({ + content: { + padding: theme.spacing(0, 5, 2, 5), + }, + actions: { + padding: theme.spacing(2, 5), + }, + radio: { + margin: theme.spacing(-0.5, 0, -0.5, 0), + }, + icon: { + color: theme.palette.primary.dark, + margin: theme.spacing(2.5, 2.5, 0, 0), + padding: 0, + }, + }), +); + +export const useAlertStatusSummaryButtonStyles = makeStyles(() => ({ + icon: { + transform: 'transform 5s', + }, + clicked: { + transform: 'rotate(180deg)', + }, +})); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 852330acc3..aae2800573 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -163,8 +163,8 @@ export type MockScrollProviderProps = PropsWithChildren<{}>; export const MockScrollProvider = ({ children }: MockScrollProviderProps) => { const defaultContext: ScrollContextProps = { - scrollTo: null, - setScrollTo: jest.fn(), + scroll: null, + setScroll: jest.fn(), }; return ( diff --git a/plugins/explore-react/.eslintrc.js b/plugins/explore-react/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/explore-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/explore-react/CHANGELOG.md b/plugins/explore-react/CHANGELOG.md new file mode 100644 index 0000000000..1b58da0f04 --- /dev/null +++ b/plugins/explore-react/CHANGELOG.md @@ -0,0 +1,14 @@ +# @backstage/plugin-explore-react + +## 0.0.2 + +### Patch Changes + +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [54c7d02f7] + - @backstage/core@0.6.0 diff --git a/plugins/explore-react/README.md b/plugins/explore-react/README.md new file mode 100644 index 0000000000..4a881955df --- /dev/null +++ b/plugins/explore-react/README.md @@ -0,0 +1,4 @@ +# explore-react + +This package provides helpers to the `explore` plugin that can be imported by +any other plugin or app. diff --git a/plugins/explore-react/package.json b/plugins/explore-react/package.json new file mode 100644 index 0000000000..924c723a67 --- /dev/null +++ b/plugins/explore-react/package.json @@ -0,0 +1,48 @@ +{ + "name": "@backstage/plugin-explore-react", + "version": "0.0.2", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/explore-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli plugin:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "clean": "backstage-cli clean", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack" + }, + "dependencies": { + "@backstage/core": "^0.6.0" + }, + "devDependencies": { + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.6", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/kubernetes/src/components/Services/index.ts b/plugins/explore-react/src/index.ts similarity index 93% rename from plugins/kubernetes/src/components/Services/index.ts rename to plugins/explore-react/src/index.ts index d52ebf5f14..d46f5d4f27 100644 --- a/plugins/kubernetes/src/components/Services/index.ts +++ b/plugins/explore-react/src/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Services } from './Services'; + +export * from './tools'; diff --git a/plugins/kubernetes/src/components/Ingresses/index.ts b/plugins/explore-react/src/setupTests.ts similarity index 93% rename from plugins/kubernetes/src/components/Ingresses/index.ts rename to plugins/explore-react/src/setupTests.ts index ecc67480db..825bcd4115 100644 --- a/plugins/kubernetes/src/components/Ingresses/index.ts +++ b/plugins/explore-react/src/setupTests.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { Ingresses } from './Ingresses'; + +import '@testing-library/jest-dom'; diff --git a/plugins/explore-react/src/tools/api.test.ts b/plugins/explore-react/src/tools/api.test.ts new file mode 100644 index 0000000000..386d66ead2 --- /dev/null +++ b/plugins/explore-react/src/tools/api.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 { exploreToolsConfigRef } from './api'; + +describe('api', () => { + // This is a dummy test to have at least one test in the package. + it('api ref exists', () => { + expect(exploreToolsConfigRef.id).toBe('plugin.explore.toolsconfig'); + }); +}); diff --git a/plugins/explore-react/src/tools/api.ts b/plugins/explore-react/src/tools/api.ts new file mode 100644 index 0000000000..5d96033546 --- /dev/null +++ b/plugins/explore-react/src/tools/api.ts @@ -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 { createApiRef } from '@backstage/core'; + +export const exploreToolsConfigRef = createApiRef({ + id: 'plugin.explore.toolsconfig', + description: 'Used to configure tools displayed in the explore plugin', +}); + +export type ExploreTool = { + title: string; + description?: string; + url: string; + image: string; + tags?: string[]; + lifecycle?: string; + newsTag?: string; +}; + +export interface ExploreToolsConfig { + getTools: () => Promise; +} diff --git a/plugins/explore-react/src/tools/index.ts b/plugins/explore-react/src/tools/index.ts new file mode 100644 index 0000000000..7f727881ca --- /dev/null +++ b/plugins/explore-react/src/tools/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 { exploreToolsConfigRef } from './api'; +export type { ExploreTool, ExploreToolsConfig } from './api'; diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md index a9e22fc5ed..0d2daf37b5 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,48 @@ # @backstage/plugin-explore +## 0.2.4 + +### Patch Changes + +- 54c7d02f7: Introduce `TabbedLayout` for creating tabs that are routed. + + ```typescript + + +
This is rendered under /example/anything-here route
+
+
+ ``` + +- 806929fe2: Rework the explore plugin to allow the user to explore things in the ecosystem, + including tools and domains. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + - @backstage/plugin-explore-react@0.0.2 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] + - @backstage/core@0.5.0 + ## 0.2.2 ### Patch Changes diff --git a/plugins/explore/README.md b/plugins/explore/README.md index d5453e594c..ec00cb4dee 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -1,7 +1,32 @@ -# Title +# explore Welcome to the explore plugin! +This plugin helps to visualize the domains and tools in your ecosystem. -## Sub-section 1 +## Getting started -## Sub-section 2 +To install the plugin, include the following import your `plugins.ts`: + +```typescript +export { explorePlugin } from '@backstage/plugin-explore'; +``` + +Register the route in `App.tsx`: + +```typescript +import { ExplorePage } from '@backstage/plugin-explore'; + +... + +} /> +``` + +Add a link to the sidebar in `Root.tsx`: + +```typescript +import LayersIcon from '@material-ui/icons/Layers'; + +... + + +``` diff --git a/plugins/explore/dev/index.tsx b/plugins/explore/dev/index.tsx index 812a5585d4..9a7b66a7b5 100644 --- a/plugins/explore/dev/index.tsx +++ b/plugins/explore/dev/index.tsx @@ -14,7 +14,59 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { exploreToolsConfigRef } from '@backstage/plugin-explore-react'; +import React from 'react'; +import { ExplorePage, explorePlugin } from '../src'; +import { exampleTools } from '../src/util/examples'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerPlugin(explorePlugin) + .registerApi({ + api: exploreToolsConfigRef, + deps: {}, + factory: () => ({ + async getTools() { + return exampleTools; + }, + }), + }) + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + ({ + async getEntities() { + const domainNames = [ + 'playback', + 'artists', + 'payments', + 'analytics', + 'songs', + 'devops', + ]; + + return { + items: domainNames.map( + (n, i) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: n, + description: `Everything about ${n}`, + tags: i % 2 === 0 ? [n] : undefined, + }, + spec: { + owner: `${n}@example.com`, + }, + } as Entity), + ), + }; + }, + } as CatalogApi), + }) + .addPage({ element: , title: 'Explore' }) + .render(); diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 4ec76ad812..e9d21cc221 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.2.2", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,20 +30,23 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/plugin-explore-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "classnames": "^2.2.6", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/explore/src/components/DomainCard/DomainCard.test.tsx b/plugins/explore/src/components/DomainCard/DomainCard.test.tsx new file mode 100644 index 0000000000..9d409051f6 --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCard.test.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 { DomainEntity } from '@backstage/catalog-model'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { DomainCard } from './DomainCard'; + +describe('', () => { + it('renders a domain card', () => { + const entity: DomainEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'artists', + description: 'Everything about artists', + tags: ['a-tag'], + }, + spec: { + owner: 'guest', + }, + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('Everything about artists')).toBeInTheDocument(); + expect(getByText('a-tag')).toBeInTheDocument(); + expect(getByText('Explore').parentElement).toHaveAttribute( + 'href', + '/catalog/default/domain/artists', + ); + }); +}); diff --git a/plugins/explore/src/components/DomainCard/DomainCard.tsx b/plugins/explore/src/components/DomainCard/DomainCard.tsx new file mode 100644 index 0000000000..2c0126b94a --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCard.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DomainEntity } from '@backstage/catalog-model'; +import { ItemCard } from '@backstage/core'; +import { + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { generatePath } from 'react-router-dom'; + +type DomainCardProps = { + entity: DomainEntity; +}; + +export const DomainCard = ({ entity }: DomainCardProps) => ( + +); diff --git a/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx b/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx new file mode 100644 index 0000000000..bad3ca581b --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCardGrid.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 { DomainEntity } from '@backstage/catalog-model'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { DomainCardGrid } from './DomainCardGrid'; + +describe('', () => { + it('renders a grid of domain cards', () => { + const entities: DomainEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'playback', + }, + spec: { + owner: 'guest', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'artists', + }, + spec: { + owner: 'guest', + }, + }, + ]; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('playback')).toBeInTheDocument(); + }); +}); diff --git a/plugins/explore/src/components/DomainCard/DomainCardGrid.tsx b/plugins/explore/src/components/DomainCard/DomainCardGrid.tsx new file mode 100644 index 0000000000..b4a13aa39f --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCardGrid.tsx @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DomainEntity } from '@backstage/catalog-model'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { DomainCard } from '.'; + +type DomainCardGridProps = { + entities: DomainEntity[]; +}; + +export const DomainCardGrid = ({ entities }: DomainCardGridProps) => ( + + {entities.map((e, i) => ( + + + + ))} + +); diff --git a/plugins/explore/src/components/DomainCard/index.ts b/plugins/explore/src/components/DomainCard/index.ts new file mode 100644 index 0000000000..6acf10800a --- /dev/null +++ b/plugins/explore/src/components/DomainCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { DomainCard } from './DomainCard'; +export { DomainCardGrid } from './DomainCardGrid'; diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx new file mode 100644 index 0000000000..4846565bdd --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.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 { DomainEntity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { DomainExplorerContent } from './DomainExplorerContent'; + +describe('', () => { + const catalogApi: jest.Mocked = { + addLocation: jest.fn(_a => new Promise(() => {})), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + + {children} + + + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders a grid of domains', async () => { + const entities: DomainEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'playback', + }, + spec: { + owner: 'guest', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'artists', + }, + spec: { + owner: 'guest', + }, + }, + ]; + catalogApi.getEntities.mockResolvedValue({ items: entities }); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('playback')).toBeInTheDocument(); + }); + }); + + it('renders empty state', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => + expect(getByText('No domains to display')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx new file mode 100644 index 0000000000..ca00c525c9 --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DomainEntity } from '@backstage/catalog-model'; +import { + Content, + ContentHeader, + EmptyState, + Progress, + SupportButton, + useApi, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Button } from '@material-ui/core'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { DomainCardGrid } from '../DomainCard'; + +export const DomainExplorerContent = () => { + const catalogApi = useApi(catalogApiRef); + const { value: entities, loading } = useAsync(async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'domain' }, + }); + + return response.items as DomainEntity[]; + }, [catalogApi]); + + return ( + + + Discover the domains in your ecosystem. + + + {loading && } + {!loading && (!entities || entities.length === 0) && ( + + Read more + + } + /> + )} + {!loading && entities && } + + ); +}; diff --git a/plugins/explore/src/components/DomainExplorerContent/index.ts b/plugins/explore/src/components/DomainExplorerContent/index.ts new file mode 100644 index 0000000000..4012332006 --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { DomainExplorerContent } from './DomainExplorerContent'; diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx similarity index 51% rename from plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx rename to plugins/explore/src/components/ExplorePage/ExplorePage.tsx index fe99e70575..3f9394a1ab 100644 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx +++ b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,28 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { configApiRef, Header, Page, useApi } from '@backstage/core'; import React from 'react'; -import { Content, Header, Page } from '@backstage/core'; -import { RollbarProjectTable } from '../RollbarProjectTable/RollbarProjectTable'; -import { useRollbarEntities } from '../../hooks/useRollbarEntities'; - -export const RollbarHome = () => { - const { entities, loading, error } = useRollbarEntities(); +import { ExploreTabs } from './ExploreTabs'; +export const ExplorePage = () => { + const configApi = useApi(configApiRef); + const organizationName = + configApi.getOptionalString('organization.name') ?? 'Backstage'; return ( - +
- - - + + ); }; diff --git a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx new file mode 100644 index 0000000000..0cf28f9f1c --- /dev/null +++ b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TabbedLayout } from '@backstage/core'; +import React from 'react'; +import { DomainExplorerContent } from '../DomainExplorerContent'; +import { ToolExplorerContent } from '../ToolExplorerContent'; + +export const ExploreTabs = () => ( + + + + + + + + +); diff --git a/plugins/explore/src/components/ExplorePage/index.ts b/plugins/explore/src/components/ExplorePage/index.ts new file mode 100644 index 0000000000..b075c410c3 --- /dev/null +++ b/plugins/explore/src/components/ExplorePage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ExplorePage } from './ExplorePage'; diff --git a/plugins/explore/src/components/ExploreCard.test.js b/plugins/explore/src/components/ToolCard/ToolCard.test.tsx similarity index 70% rename from plugins/explore/src/components/ExploreCard.test.js rename to plugins/explore/src/components/ToolCard/ToolCard.test.tsx index 45652015a5..b3c1737ae6 100644 --- a/plugins/explore/src/components/ExploreCard.test.js +++ b/plugins/explore/src/components/ToolCard/ToolCard.test.tsx @@ -14,11 +14,10 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; - -import ExploreCard from './ExploreCard'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { ToolCard } from './ToolCard'; const minProps = { card: { @@ -30,37 +29,31 @@ const minProps = { }, }; -describe('', () => { +describe('', () => { it('renders without exploding', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText('Explore')).toBeInTheDocument(); }); it('renders props correctly', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText(minProps.card.title)).toBeInTheDocument(); expect(getByText(minProps.card.description)).toBeInTheDocument(); }); it('should link out', () => { - const rendered = render(wrapInTestApp()); + const rendered = render(wrapInTestApp()); const anchor = rendered.container.querySelector('a'); - expect(anchor.href).toBe(minProps.card.url); + expect(anchor).toHaveAttribute('href', minProps.card.url); }); it('renders default description when missing', () => { - const propsWithoutDescription = { - card: { - card: { - title: 'Title', - url: 'http://spotify.com/', - image: 'https://developer.spotify.com/assets/WebAPI_intro.png', - }, - }, + const card = { + title: 'Title', + url: 'http://spotify.com/', + image: 'https://developer.spotify.com/assets/WebAPI_intro.png', }; - const { getByText } = render( - wrapInTestApp(), - ); + const { getByText } = render(wrapInTestApp()); expect(getByText('Description missing')).toBeInTheDocument(); }); @@ -74,13 +67,13 @@ describe('', () => { }, }; const { queryByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); expect(queryByText('GA')).not.toBeInTheDocument(); }); it('renders tags correctly', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText(minProps.card.tags[0])).toBeInTheDocument(); expect(getByText(minProps.card.tags[1])).toBeInTheDocument(); }); diff --git a/plugins/explore/src/components/ExploreCard.tsx b/plugins/explore/src/components/ToolCard/ToolCard.tsx similarity index 91% rename from plugins/explore/src/components/ExploreCard.tsx rename to plugins/explore/src/components/ToolCard/ToolCard.tsx index 8d1348c116..c6d812b098 100644 --- a/plugins/explore/src/components/ExploreCard.tsx +++ b/plugins/explore/src/components/ToolCard/ToolCard.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import React from 'react'; -import classNames from 'classnames'; +import { ExploreTool } from '@backstage/plugin-explore-react'; +import { BackstageTheme } from '@backstage/theme'; import { Button, Card, @@ -23,10 +23,13 @@ import { CardContent, CardMedia, Chip, - Typography, makeStyles, + Typography, } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; +import classNames from 'classnames'; +import React from 'react'; + +// TODO: Align styling between Domain and ToolCard const useStyles = makeStyles(theme => ({ card: { @@ -65,22 +68,12 @@ const useStyles = makeStyles(theme => ({ }, })); -export type CardData = { - title: string; - description: string; - url: string; - image: string; - tags?: string[]; - lifecycle?: string; - newsTag?: string; -}; - type Props = { - card: CardData; + card: ExploreTool; objectFit?: 'cover' | 'contain'; }; -const ExploreCard = ({ card, objectFit }: Props) => { +export const ToolCard = ({ card, objectFit }: Props) => { const classes = useStyles(); const { title, description, url, image, lifecycle, newsTag, tags } = card; @@ -130,5 +123,3 @@ const ExploreCard = ({ card, objectFit }: Props) => { ); }; - -export default ExploreCard; diff --git a/plugins/explore/src/components/ToolCard/ToolCardGrid.tsx b/plugins/explore/src/components/ToolCard/ToolCardGrid.tsx new file mode 100644 index 0000000000..5b6fbd1834 --- /dev/null +++ b/plugins/explore/src/components/ToolCard/ToolCardGrid.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 { ExploreTool } from '@backstage/plugin-explore-react'; +import { BackstageTheme } from '@backstage/theme'; +import { makeStyles } from '@material-ui/core'; +import React from 'react'; +import { ToolCard } from './ToolCard'; + +const useStyles = makeStyles(theme => ({ + container: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, 296px)', + gridGap: theme.spacing(3), + marginBottom: theme.spacing(6), + }, +})); + +type ToolCardGridProps = { + tools: ExploreTool[]; +}; + +export const ToolCardGrid = ({ tools }: ToolCardGridProps) => { + const classes = useStyles(); + + return ( +
+ {tools.map((card: ExploreTool, ix: any) => ( + + ))} +
+ ); +}; diff --git a/plugins/explore/src/components/ToolCard/index.ts b/plugins/explore/src/components/ToolCard/index.ts new file mode 100644 index 0000000000..84599aa163 --- /dev/null +++ b/plugins/explore/src/components/ToolCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ToolCard } from './ToolCard'; +export { ToolCardGrid } from './ToolCardGrid'; diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx new file mode 100644 index 0000000000..b0e1330ff3 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx @@ -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 { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + ExploreTool, + exploreToolsConfigRef, +} from '@backstage/plugin-explore-react'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { ToolExplorerContent } from './ToolExplorerContent'; + +describe('', () => { + const exploreToolsConfigApi: jest.Mocked = { + getTools: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + + + {children} + + + + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders a grid of tools', async () => { + const tools: ExploreTool[] = [ + { + title: 'Lighthouse', + description: + "Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website.", + url: '/lighthouse', + image: + 'https://raw.githubusercontent.com/GoogleChrome/lighthouse/8b3d7f052b2e64dd857e741d7395647f487697e7/assets/lighthouse-logo.png', + tags: ['web', 'seo', 'accessibility', 'performance'], + }, + { + title: 'Tech Radar', + description: + 'Tech Radar is a list of technologies, complemented by an assessment result, called ring assignment.', + url: '/tech-radar', + image: + 'https://storage.googleapis.com/wf-blogs-engineering-media/2018/09/fe13bb32-wf-tech-radar-hero-1024x597.png', + tags: ['standards', 'landscape'], + }, + ]; + exploreToolsConfigApi.getTools.mockResolvedValue(tools); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(getByText('Lighthouse')).toBeInTheDocument(); + expect(getByText('Tech Radar')).toBeInTheDocument(); + }); + }); + + it('renders empty state', async () => { + exploreToolsConfigApi.getTools.mockResolvedValue([]); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => + expect(getByText('No tools to display')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx new file mode 100644 index 0000000000..8b8b3f0a61 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Content, + ContentHeader, + EmptyState, + Progress, + SupportButton, + useApi, +} from '@backstage/core'; +import { exploreToolsConfigRef } from '@backstage/plugin-explore-react'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { ToolCardGrid } from '../ToolCard'; + +export const ToolExplorerContent = () => { + const exploreToolsConfigApi = useApi(exploreToolsConfigRef); + const { value: tools, loading } = useAsync(async () => { + return await exploreToolsConfigApi.getTools(); + }, [exploreToolsConfigApi]); + + return ( + + + Discover the tools in your ecosystem. + + + {loading && } + {!loading && (!tools || tools.length === 0) && ( + + )} + {!loading && tools && } + + ); +}; diff --git a/plugins/explore/src/components/ToolExplorerContent/index.ts b/plugins/explore/src/components/ToolExplorerContent/index.ts new file mode 100644 index 0000000000..8fb3072d46 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { ToolExplorerContent } from './ToolExplorerContent'; diff --git a/plugins/explore/src/extensions.tsx b/plugins/explore/src/extensions.tsx new file mode 100644 index 0000000000..cdf43d3035 --- /dev/null +++ b/plugins/explore/src/extensions.tsx @@ -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 { createRoutableExtension } from '@backstage/core'; +import { explorePlugin } from './plugin'; +import { exploreRouteRef } from './routes'; + +export const ExplorePage = explorePlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ExplorePage').then(m => m.ExplorePage), + mountPoint: exploreRouteRef, + }), +); diff --git a/plugins/explore/src/index.ts b/plugins/explore/src/index.ts index ff7857cacd..70a00f5bbb 100644 --- a/plugins/explore/src/index.ts +++ b/plugins/explore/src/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ -export { plugin } from './plugin'; -export { Router } from './components/Router'; +export * from './extensions'; +export { explorePlugin } from './plugin'; +export * from './routes'; diff --git a/plugins/explore/src/plugin.test.ts b/plugins/explore/src/plugin.test.ts index d6503c038b..652a0590dc 100644 --- a/plugins/explore/src/plugin.test.ts +++ b/plugins/explore/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { explorePlugin } from './plugin'; describe('explore', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(explorePlugin).toBeDefined(); }); }); diff --git a/plugins/explore/src/plugin.ts b/plugins/explore/src/plugin.ts index 75ea892242..27573eaeb9 100644 --- a/plugins/explore/src/plugin.ts +++ b/plugins/explore/src/plugin.ts @@ -14,9 +14,27 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; +import { createApiFactory, createPlugin } from '@backstage/core'; +import { exploreToolsConfigRef } from '@backstage/plugin-explore-react'; +import { exploreRouteRef } from './routes'; +import { exampleTools } from './util/examples'; -export const rootRouteRef = createRouteRef({ path: '', title: 'Explore' }); -export const plugin = createPlugin({ +export const explorePlugin = createPlugin({ id: 'explore', + apis: [ + // Register a default for exploreToolsConfigRef, you may want to override + // the API locally in your app. + createApiFactory({ + api: exploreToolsConfigRef, + deps: {}, + factory: () => ({ + async getTools() { + return exampleTools; + }, + }), + }), + ], + routes: { + explore: exploreRouteRef, + }, }); diff --git a/plugins/explore/src/routes.ts b/plugins/explore/src/routes.ts new file mode 100644 index 0000000000..c41a53128f --- /dev/null +++ b/plugins/explore/src/routes.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 { createRouteRef } from '@backstage/core'; + +const NoIcon = () => null; + +export const exploreRouteRef = createRouteRef({ + icon: NoIcon, + title: 'Explore', +}); diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/util/examples.ts similarity index 75% rename from plugins/explore/src/components/ExplorePluginPage.tsx rename to plugins/explore/src/util/examples.ts index f3e75e0011..d0a4cdd1f4 100644 --- a/plugins/explore/src/components/ExplorePluginPage.tsx +++ b/plugins/explore/src/util/examples.ts @@ -14,28 +14,7 @@ * limitations under the License. */ -import React from 'react'; -import { makeStyles, Typography } from '@material-ui/core'; -import { - Content, - ContentHeader, - Header, - Page, - SupportButton, -} from '@backstage/core'; -import ExploreCard, { CardData } from './ExploreCard'; -import { BackstageTheme } from '@backstage/theme'; - -const useStyles = makeStyles(theme => ({ - container: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, 296px)', - gridGap: theme.spacing(3), - marginBottom: theme.spacing(6), - }, -})); - -const toolsCards = [ +export const exampleTools = [ { title: 'New Relic', description: @@ -112,28 +91,3 @@ const toolsCards = [ tags: ['rollbar', 'monitoring', 'errors'], }, ]; - -export const ExplorePluginPage = () => { - const classes = useStyles(); - - return ( - -
- - - - Explore tools available in Backstage - - -
- {toolsCards.map((card: CardData, ix: any) => ( - - ))} -
-
- - ); -}; diff --git a/plugins/fossa/CHANGELOG.md b/plugins/fossa/CHANGELOG.md index e1c9ef1717..de1e2fda06 100644 --- a/plugins/fossa/CHANGELOG.md +++ b/plugins/fossa/CHANGELOG.md @@ -1,5 +1,48 @@ # @backstage/plugin-fossa +## 0.2.0 + +### Minor Changes + +- 5ac9df899: Port FOSSA plugin to new extension model. + + If you are using the FOSSA plugin adjust the plugin import from `plugin` to + `fossaPlugin` and replace `` with ``. + +### Patch Changes + +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.1.2 + +### Patch Changes + +- 9f618bf16: Request a sorted response list to select the project with the correct title. The FOSSA API + matches title searches with "starts with" so previously it used the response for `my-project-part` + if you searched for `my-project`. +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + ## 0.1.1 ### Patch Changes diff --git a/plugins/fossa/README.md b/plugins/fossa/README.md index 489aef90ab..6e53ced0cf 100644 --- a/plugins/fossa/README.md +++ b/plugins/fossa/README.md @@ -19,21 +19,21 @@ yarn add @backstage/plugin-fossa ```js // packages/app/src/plugins.ts -export { plugin as Fossa } from '@backstage/plugin-fossa'; +export { fossaPlugin } from '@backstage/plugin-fossa'; ``` -3. Add the `FossaCard` to the EntityPage: +3. Add the `EntityFossaCard` to the EntityPage: ```jsx // packages/app/src/components/catalog/EntityPage.tsx -import { FossaCard } from '@backstage/plugin-fossa'; +import { EntityFossaCard } from '@backstage/plugin-fossa'; const OverviewContent = ({ entity }: { entity: Entity }) => ( // ... - + // ... diff --git a/plugins/fossa/dev/index.tsx b/plugins/fossa/dev/index.tsx index 7846324b25..06c71c48b7 100644 --- a/plugins/fossa/dev/index.tsx +++ b/plugins/fossa/dev/index.tsx @@ -14,21 +14,28 @@ * limitations under the License. */ -import { createDevApp } from '@backstage/dev-utils'; -import { - Content, - createPlugin, - createRouteRef, - Header, - Page, -} from '@backstage/core'; -import React from 'react'; -import { Grid } from '@material-ui/core'; -import { FossaApi, fossaApiRef } from '../src/api'; -import { FossaCard } from '../src'; import { Entity } from '@backstage/catalog-model'; +import { Content, Header, Page } from '@backstage/core'; +import { createDevApp } from '@backstage/dev-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { EntityFossaCard } from '../src'; +import { FossaApi, fossaApiRef } from '../src/api'; import { FOSSA_PROJECT_NAME_ANNOTATION } from '../src/components/useProjectName'; +const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [FOSSA_PROJECT_NAME_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + createDevApp() .registerApi({ api: fossaApiRef, @@ -76,58 +83,51 @@ createDevApp() }, } as FossaApi), }) - .registerPlugin( - createPlugin({ - id: 'fossa-demo', - register({ router }) { - const entity = (name?: string) => - ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - annotations: { - [FOSSA_PROJECT_NAME_ANNOTATION]: name, - }, - name: name, - }, - } as Entity); - - const ExamplePage = () => ( - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - ); - - router.addRoute( - createRouteRef({ path: '/', title: 'Fossa' }), - ExamplePage, - ); - }, - }), - ) + .addPage({ + title: 'Entity Content', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }) .render(); diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index b7878b5b29..74f04e3fd0 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-fossa", - "version": "0.1.1", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,8 +44,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/fossa/src/api/FossaClient.test.ts b/plugins/fossa/src/api/FossaClient.test.ts index c01dd09327..dd0fdeff5e 100644 --- a/plugins/fossa/src/api/FossaClient.test.ts +++ b/plugins/fossa/src/api/FossaClient.test.ts @@ -37,12 +37,13 @@ describe('FossaClient', () => { server.use( rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'count=1&title=our-service&organizationId=8736', + 'count=1&sort=title+&title=our-service&organizationId=8736', ); return res( ctx.json([ { locator: 'custom+8736/our-service', + title: 'our-service', default_branch: 'develop', revisions: [ { @@ -73,12 +74,13 @@ describe('FossaClient', () => { server.use( rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'count=1&title=our-service&organizationId=8736', + 'count=1&sort=title+&title=our-service&organizationId=8736', ); return res( ctx.json([ { locator: 'custom+8736/our-service', + title: 'our-service', default_branch: 'refs/master', revisions: [ { @@ -104,13 +106,43 @@ describe('FossaClient', () => { } as FindingSummary); }); + it('should handle empty result', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'count=1&sort=title+&title=our-service&organizationId=8736', + ); + return res(ctx.json([])); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + + it('should ignore result with invalid title', async () => { + server.use( + rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { + expect(req.url.searchParams.toString()).toBe( + 'count=1&sort=title+&title=our-service&organizationId=8736', + ); + return res(ctx.json([{ title: 'our-service-2' }])); + }), + ); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toBeUndefined(); + }); + it('should skip organizationId', async () => { client = new FossaClient({ discoveryApi }); server.use( rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'count=1&title=our-service', + 'count=1&sort=title+&title=our-service', ); return res(ctx.status(404)); }), @@ -125,7 +157,7 @@ describe('FossaClient', () => { server.use( rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'count=1&title=our-service&organizationId=8736', + 'count=1&sort=title+&title=our-service&organizationId=8736', ); return res(ctx.status(404)); }), diff --git a/plugins/fossa/src/api/FossaClient.ts b/plugins/fossa/src/api/FossaClient.ts index 10f688393b..05a889177a 100644 --- a/plugins/fossa/src/api/FossaClient.ts +++ b/plugins/fossa/src/api/FossaClient.ts @@ -46,11 +46,12 @@ export class FossaClient implements FossaApi { projectTitle: string, ): Promise { const project = await this.callApi( - `projects?count=1&title=${projectTitle}${ + `projects?count=1&sort=title+&title=${projectTitle}${ this.organizationId ? `&organizationId=${this.organizationId}` : '' }`, ); - if (!project) { + + if (!project || project.length === 0 || project[0].title !== projectTitle) { return undefined; } diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.tsx index d1ac06d365..4cc27f5d03 100644 --- a/plugins/fossa/src/components/FossaCard/FossaCard.tsx +++ b/plugins/fossa/src/components/FossaCard/FossaCard.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { EmptyState, InfoCard, @@ -22,15 +21,16 @@ import { Progress, useApi, } from '@backstage/core'; -import { useAsync } from 'react-use'; -import { Entity } from '@backstage/catalog-model'; -import { fossaApiRef } from '../../api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { Grid, Tooltip } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { fossaApiRef } from '../../api'; import { FOSSA_PROJECT_NAME_ANNOTATION, useProjectName, } from '../useProjectName'; -import { Grid, Tooltip } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ numberError: { @@ -65,13 +65,8 @@ const useStyles = makeStyles(theme => ({ }, })); -export const FossaCard = ({ - entity, - variant = 'gridItem', -}: { - entity: Entity; - variant?: string; -}) => { +export const FossaCard = () => { + const { entity } = useEntity(); const fossaApi = useApi(fossaApiRef); const projectTitle = useProjectName(entity); @@ -96,7 +91,7 @@ export const FossaCard = ({ import('./components/FossaCard').then(m => m.FossaCard), + }, + }), +); diff --git a/plugins/fossa/src/index.ts b/plugins/fossa/src/index.ts index c35c534122..f1b645c29b 100644 --- a/plugins/fossa/src/index.ts +++ b/plugins/fossa/src/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { plugin } from './plugin'; -export * from './components'; +export { fossaPlugin } from './plugin'; +export { EntityFossaCard } from './extensions'; diff --git a/plugins/fossa/src/plugin.test.ts b/plugins/fossa/src/plugin.test.ts index 4f9b00a02b..9aed1387c5 100644 --- a/plugins/fossa/src/plugin.test.ts +++ b/plugins/fossa/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { fossaPlugin } from './plugin'; describe('fossa', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(fossaPlugin).toBeDefined(); }); }); diff --git a/plugins/fossa/src/plugin.ts b/plugins/fossa/src/plugin.ts index d1fe621018..cb1de3911f 100644 --- a/plugins/fossa/src/plugin.ts +++ b/plugins/fossa/src/plugin.ts @@ -22,7 +22,7 @@ import { } from '@backstage/core'; import { fossaApiRef, FossaClient } from './api'; -export const plugin = createPlugin({ +export const fossaPlugin = createPlugin({ id: 'fossa', apis: [ createApiFactory({ diff --git a/plugins/gcp-projects/CHANGELOG.md b/plugins/gcp-projects/CHANGELOG.md index 3f21b36084..92f1da463e 100644 --- a/plugins/gcp-projects/CHANGELOG.md +++ b/plugins/gcp-projects/CHANGELOG.md @@ -1,5 +1,29 @@ # @backstage/plugin-gcp-projects +## 0.2.4 + +### Patch Changes + +- bc5082a00: Migrate to new composability API, exporting the plugin as `gcpProjectsPlugin` and page as `GcpProjectsPage`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [54c7d02f7] + - @backstage/core@0.6.0 + - @backstage/theme@0.2.3 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] + - @backstage/core@0.5.0 + ## 0.2.2 ### Patch Changes diff --git a/plugins/gcp-projects/dev/index.tsx b/plugins/gcp-projects/dev/index.tsx index 812a5585d4..b87d4b7d47 100644 --- a/plugins/gcp-projects/dev/index.tsx +++ b/plugins/gcp-projects/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { gcpProjectsPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(gcpProjectsPlugin).render(); diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 823a2fa37c..fced060d99 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-gcp-projects", - "version": "0.2.2", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,19 +30,19 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/core": "^0.6.0", + "@backstage/theme": "^0.2.3", "@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": "^5.2.0", + "react-router-dom": "^6.0.0-beta.0", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx b/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx new file mode 100644 index 0000000000..158347a9ff --- /dev/null +++ b/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import { NewProjectPage } from '../NewProjectPage'; +import { ProjectDetailsPage } from '../ProjectDetailsPage'; +import { ProjectListPage } from '../ProjectListPage'; + +export const GcpProjectsPage = () => ( + + } /> + } /> + } /> + +); diff --git a/plugins/gcp-projects/src/components/GcpProjectsPage/index.ts b/plugins/gcp-projects/src/components/GcpProjectsPage/index.ts new file mode 100644 index 0000000000..a39db43c16 --- /dev/null +++ b/plugins/gcp-projects/src/components/GcpProjectsPage/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 { GcpProjectsPage } from './GcpProjectsPage'; diff --git a/plugins/gcp-projects/src/index.ts b/plugins/gcp-projects/src/index.ts index d67bc6a864..af3f0bc4d9 100644 --- a/plugins/gcp-projects/src/index.ts +++ b/plugins/gcp-projects/src/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + gcpProjectsPlugin, + gcpProjectsPlugin as plugin, + GcpProjectsPage, +} from './plugin'; export * from './api'; diff --git a/plugins/gcp-projects/src/plugin.test.ts b/plugins/gcp-projects/src/plugin.test.ts index 86e909995f..78eddd8603 100644 --- a/plugins/gcp-projects/src/plugin.test.ts +++ b/plugins/gcp-projects/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { gcpProjectsPlugin } from './plugin'; describe('gcp-projects', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(gcpProjectsPlugin).toBeDefined(); }); }); diff --git a/plugins/gcp-projects/src/plugin.ts b/plugins/gcp-projects/src/plugin.ts index 2baf3bce7d..9126716d55 100644 --- a/plugins/gcp-projects/src/plugin.ts +++ b/plugins/gcp-projects/src/plugin.ts @@ -17,29 +17,20 @@ import { createApiFactory, createPlugin, - createRouteRef, + createRoutableExtension, googleAuthApiRef, } from '@backstage/core'; import { gcpApiRef, GcpClient } from './api'; import { NewProjectPage } from './components/NewProjectPage'; import { ProjectDetailsPage } from './components/ProjectDetailsPage'; import { ProjectListPage } from './components/ProjectListPage'; +import { rootRouteRef, projectRouteRef, newProjectRouteRef } from './routes'; -export const rootRouteRef = createRouteRef({ - path: '/gcp-projects', - title: 'GCP Projects', -}); -export const projectRouteRef = createRouteRef({ - path: '/gcp-projects/project', - title: 'GCP Project Page', -}); -export const newProjectRouteRef = createRouteRef({ - path: '/gcp-projects/new', - title: 'GCP Project Page', -}); - -export const plugin = createPlugin({ +export const gcpProjectsPlugin = createPlugin({ id: 'gcp-projects', + routes: { + root: rootRouteRef, + }, apis: [ createApiFactory({ api: gcpApiRef, @@ -55,3 +46,11 @@ export const plugin = createPlugin({ router.addRoute(newProjectRouteRef, NewProjectPage); }, }); + +export const GcpProjectsPage = gcpProjectsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/GcpProjectsPage').then(m => m.GcpProjectsPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/rollbar/src/routes.ts b/plugins/gcp-projects/src/routes.ts similarity index 67% rename from plugins/rollbar/src/routes.ts rename to plugins/gcp-projects/src/routes.ts index b514dff3c8..ff9bf9d85c 100644 --- a/plugins/rollbar/src/routes.ts +++ b/plugins/gcp-projects/src/routes.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,21 +16,15 @@ import { createRouteRef } from '@backstage/core'; -const NoIcon = () => null; - export const rootRouteRef = createRouteRef({ - icon: NoIcon, - path: '/rollbar', - title: 'Rollbar Home', + path: '/gcp-projects', + title: 'GCP Projects', }); - -export const entityRouteRef = createRouteRef({ - path: '/rollbar/:optionalNamespaceAndName', - title: 'Rollbar', +export const projectRouteRef = createRouteRef({ + path: '/gcp-projects/project', + title: 'GCP Project Page', }); - -export const catalogRouteRef = createRouteRef({ - icon: NoIcon, - path: '', - title: 'Rollbar', +export const newProjectRouteRef = createRouteRef({ + path: '/gcp-projects/new', + title: 'GCP Project Page', }); diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index 2d69b1499d..a9bbf67a9a 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,10 +1,74 @@ # @backstage/plugin-github-actions +## 0.3.1 + +### Patch Changes + +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [c4abcdb60] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [064c513e1] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [3149bfe63] +- Updated dependencies [54c7d02f7] +- Updated dependencies [2e62aea6f] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/integration@0.3.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.3.0 + +### Minor Changes + +- 46bba09ea: Support GHE + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [0b135e7e0] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [fa8ba330a] +- Updated dependencies [a187b8ad0] +- Updated dependencies [ed6baab66] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/integration@0.3.0 + - @backstage/plugin-catalog@0.2.12 + +## 0.2.7 + +### Patch Changes + +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- a6f9dca0d: Remove dependency on `@backstage/core-api`. No plugin should ever depend on that package; it's an internal concern whose important bits are re-exported by `@backstage/core` which is the public facing dependency to use. +- Updated dependencies [f3b064e1c] +- Updated dependencies [c00488983] +- Updated dependencies [265a7ab30] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/plugin-catalog@0.2.11 + - @backstage/core@0.4.4 + ## 0.2.6 ### Patch Changes -- 19554f6d6: Added Github Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` +- 19554f6d6: Added GitHub Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` - Updated dependencies [d681db2b5] - Updated dependencies [1dc445e89] - Updated dependencies [342270e4d] @@ -94,7 +158,7 @@ ### Minor Changes - 28edd7d29: Create backend plugin through CLI -- d67c529ab: Adds a widget to show recent git workflow runs to the github actions plugin. The default setting is the last 5 runs across all branches but both branch and the number of runs are configurable. +- d67c529ab: Adds a widget to show recent git workflow runs to the GitHub actions plugin. The default setting is the last 5 runs across all branches but both branch and the number of runs are configurable. - 6d97d2d6f: The InfoCard variant `'height100'` is deprecated. Use variant `'gridItem'` instead. When the InfoCard is displayed as a grid item within a grid, you may want items to have the same height for all items. diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md index 1a46022c58..d47144351e 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -43,25 +43,12 @@ If you didn't clone this repo you have to do some extra work. yarn add @backstage/plugin-github-actions ``` -```js -// packages/app/src/api.ts -import { ApiRegistry } from '@backstage/core'; -import { GithubActionsClient, githubActionsApiRef } from '@backstage/plugin-github-actions'; - -const builder = ApiRegistry.builder(); -builder.add(githubActionsApiRef, new GithubActionsClient()); - -export default builder.build() as ApiHolder; -``` - -2. Add plugin itself: - ```js // packages/app/src/plugins.ts export { plugin as GithubActions } from '@backstage/plugin-github-actions'; ``` -3. Run the app with `yarn start` and the backend with `yarn --cwd packages/backend start`, navigate to `/github-actions/`. +2. Run the app with `yarn start` and the backend with `yarn --cwd packages/backend start`, navigate to `/github-actions/`. ## Features diff --git a/plugins/github-actions/dev/index.tsx b/plugins/github-actions/dev/index.tsx index 812a5585d4..9156224202 100644 --- a/plugins/github-actions/dev/index.tsx +++ b/plugins/github-actions/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { githubActionsPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(githubActionsPlugin).render(); diff --git a/plugins/github-actions/examples/sample.yaml b/plugins/github-actions/examples/sample.yaml index beda3c58ed..a765bd395d 100644 --- a/plugins/github-actions/examples/sample.yaml +++ b/plugins/github-actions/examples/sample.yaml @@ -5,7 +5,7 @@ metadata: description: backstage.io annotations: github.com/project-slug: 'backstage/backstage' - backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + backstage.io/techdocs-ref: url:https://github.com/backstage/backstage spec: type: website lifecycle: production diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index b9b0e93d6f..7d05395b42 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.2.6", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,16 +32,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/core-api": "^0.2.7", - "@backstage/plugin-catalog": "^0.2.8", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.0", + "@backstage/integration": "^0.3.2", + "@backstage/theme": "^0.2.3", "@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", + "@octokit/rest": "^18.0.12", "moment": "^2.27.0", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -51,8 +50,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/github-actions/src/api/GithubActionsApi.ts b/plugins/github-actions/src/api/GithubActionsApi.ts index bf79e74351..637bb23216 100644 --- a/plugins/github-actions/src/api/GithubActionsApi.ts +++ b/plugins/github-actions/src/api/GithubActionsApi.ts @@ -15,12 +15,7 @@ */ import { createApiRef } from '@backstage/core'; -import { - ActionsListWorkflowRunsForRepoResponseData, - ActionsGetWorkflowResponseData, - ActionsGetWorkflowRunResponseData, - EndpointInterface, -} from '@octokit/types'; +import { RestEndpointMethodTypes } from '@octokit/rest'; export const githubActionsApiRef = createApiRef({ id: 'plugin.githubactions.service', @@ -29,62 +24,70 @@ export const githubActionsApiRef = createApiRef({ export type GithubActionsApi = { listWorkflowRuns: ({ - token, + hostname, owner, repo, pageSize, page, branch, }: { - token: string; + hostname?: string; owner: string; repo: string; pageSize?: number; page?: number; branch?: string; - }) => Promise; + }) => Promise< + RestEndpointMethodTypes['actions']['listWorkflowRuns']['response']['data'] + >; getWorkflow: ({ - token, + hostname, owner, repo, id, }: { - token: string; + hostname?: string; owner: string; repo: string; id: number; - }) => Promise; + }) => Promise< + RestEndpointMethodTypes['actions']['getWorkflow']['response']['data'] + >; getWorkflowRun: ({ - token, + hostname, owner, repo, id, }: { - token: string; + hostname?: string; owner: string; repo: string; id: number; - }) => Promise; + }) => Promise< + RestEndpointMethodTypes['actions']['getWorkflowRun']['response']['data'] + >; reRunWorkflow: ({ - token, + hostname, owner, repo, runId, }: { - token: string; + hostname?: string; owner: string; repo: string; runId: number; }) => Promise; downloadJobLogsForWorkflowRun: ({ - token, + hostname, owner, repo, runId, }: { - token: string; + hostname?: string; owner: string; repo: string; runId: number; - }) => Promise; + }) => Promise< + RestEndpointMethodTypes['actions']['downloadJobLogsForWorkflowRun']['response']['data'] + >; }; diff --git a/plugins/github-actions/src/api/GithubActionsClient.ts b/plugins/github-actions/src/api/GithubActionsClient.ts index 94fb774537..a667ddc4ad 100644 --- a/plugins/github-actions/src/api/GithubActionsClient.ts +++ b/plugins/github-actions/src/api/GithubActionsClient.ts @@ -14,51 +14,70 @@ * limitations under the License. */ +import { ConfigApi, OAuthApi } from '@backstage/core'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { GithubActionsApi } from './GithubActionsApi'; -import { Octokit } from '@octokit/rest'; -import { - ActionsListWorkflowRunsForRepoResponseData, - ActionsGetWorkflowResponseData, - ActionsGetWorkflowRunResponseData, - EndpointInterface, -} from '@octokit/types'; +import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; export class GithubActionsClient implements GithubActionsApi { + private readonly configApi: ConfigApi; + private readonly githubAuthApi: OAuthApi; + + constructor(options: { configApi: ConfigApi; githubAuthApi: OAuthApi }) { + this.configApi = options.configApi; + this.githubAuthApi = options.githubAuthApi; + } + + private async getOctokit(hostname?: string): Promise { + // TODO: Get access token for the specified hostname + const token = await this.githubAuthApi.getAccessToken(['repo']); + const configs = readGitHubIntegrationConfigs( + this.configApi.getOptionalConfigArray('integrations.github') ?? [], + ); + const githubIntegrationConfig = configs.find( + v => v.host === hostname ?? 'github.com', + ); + const baseUrl = githubIntegrationConfig?.apiBaseUrl; + return new Octokit({ auth: token, baseUrl }); + } + async reRunWorkflow({ - token, + hostname, owner, repo, runId, }: { - token: string; + hostname?: string; owner: string; repo: string; runId: number; }): Promise { - return new Octokit({ auth: token }).actions.reRunWorkflow({ + const octokit = await this.getOctokit(hostname); + return octokit.actions.reRunWorkflow({ owner, repo, run_id: runId, }); } async listWorkflowRuns({ - token, + hostname, owner, repo, pageSize = 100, page = 0, branch, }: { - token: string; + hostname?: string; owner: string; repo: string; pageSize?: number; page?: number; branch?: string; - }): Promise { - const workflowRuns = await new Octokit({ - auth: token, - }).actions.listWorkflowRunsForRepo({ + }): Promise< + RestEndpointMethodTypes['actions']['listWorkflowRuns']['response']['data'] + > { + const octokit = await this.getOctokit(hostname); + const workflowRuns = await octokit.actions.listWorkflowRunsForRepo({ owner, repo, per_page: pageSize, @@ -68,17 +87,20 @@ export class GithubActionsClient implements GithubActionsApi { return workflowRuns.data; } async getWorkflow({ - token, + hostname, owner, repo, id, }: { - token: string; + hostname?: string; owner: string; repo: string; id: number; - }): Promise { - const workflow = await new Octokit({ auth: token }).actions.getWorkflow({ + }): Promise< + RestEndpointMethodTypes['actions']['getWorkflow']['response']['data'] + > { + const octokit = await this.getOctokit(hostname); + const workflow = await octokit.actions.getWorkflow({ owner, repo, workflow_id: id, @@ -86,17 +108,20 @@ export class GithubActionsClient implements GithubActionsApi { return workflow.data; } async getWorkflowRun({ - token, + hostname, owner, repo, id, }: { - token: string; + hostname?: string; owner: string; repo: string; id: number; - }): Promise { - const run = await new Octokit({ auth: token }).actions.getWorkflowRun({ + }): Promise< + RestEndpointMethodTypes['actions']['getWorkflowRun']['response']['data'] + > { + const octokit = await this.getOctokit(hostname); + const run = await octokit.actions.getWorkflowRun({ owner, repo, run_id: id, @@ -104,19 +129,20 @@ export class GithubActionsClient implements GithubActionsApi { return run.data; } async downloadJobLogsForWorkflowRun({ - token, + hostname, owner, repo, runId, }: { - token: string; + hostname?: string; owner: string; repo: string; runId: number; - }): Promise { - const workflow = await new Octokit({ - auth: token, - }).actions.downloadJobLogsForWorkflowRun({ + }): Promise< + RestEndpointMethodTypes['actions']['downloadJobLogsForWorkflowRun']['response']['data'] + > { + const octokit = await this.getOctokit(hostname); + const workflow = await octokit.actions.downloadJobLogsForWorkflowRun({ owner, repo, job_id: runId, diff --git a/plugins/github-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index 1f1e2997df..202bf5aa92 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -17,6 +17,8 @@ import React, { useEffect } from 'react'; import { useWorkflowRuns } from '../useWorkflowRuns'; import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import { Link, @@ -28,6 +30,7 @@ import { import { InfoCard, StructuredMetadataTable, + configApiRef, errorApiRef, useApi, } from '@backstage/core'; @@ -79,16 +82,22 @@ const WidgetContent = ({ }; export const LatestWorkflowRunCard = ({ - entity, branch = 'master', // Display the card full height suitable for variant, }: Props) => { + const { entity } = useEntity(); + const config = useApi(configApiRef); const errorApi = useApi(errorApiRef); + // TODO: Get github hostname from metadata annotation + const hostname = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + )[0].host; const [owner, repo] = ( entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/' ).split('/'); const [{ runs, loading, error }] = useWorkflowRuns({ + hostname, owner, repo, branch, @@ -113,17 +122,21 @@ export const LatestWorkflowRunCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; branch: string; variant?: string; }; export const LatestWorkflowsForBranchCard = ({ - entity, branch = 'master', variant, -}: Props) => ( - - - -); +}: Props) => { + const { entity } = useEntity(); + + return ( + + + + ); +}; diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx index 1d6001e038..18cc59439b 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.test.tsx @@ -14,15 +14,23 @@ * 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 { + ApiProvider, + ApiRegistry, + errorApiRef, + configApiRef, + ConfigApi, + ConfigReader, +} from '@backstage/core'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import { render } from '@testing-library/react'; +import React from 'react'; import { MemoryRouter } from 'react-router'; +import { useWorkflowRuns } from '../useWorkflowRuns'; +import type { Props as RecentWorkflowRunsCardProps } from './RecentWorkflowRunsCard'; +import { RecentWorkflowRunsCard } from './RecentWorkflowRunsCard'; jest.mock('../useWorkflowRuns', () => ({ useWorkflowRuns: jest.fn(), @@ -33,6 +41,8 @@ const mockErrorApi: jest.Mocked = { error$: jest.fn(), }; +const configApi: ConfigApi = new ConfigReader({}); + describe('', () => { const entity = { apiVersion: 'v1', @@ -69,8 +79,15 @@ describe('', () => { render( - - + + + + , diff --git a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx index 70348ece0a..4425bc8364 100644 --- a/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx +++ b/plugins/github-actions/src/components/Cards/RecentWorkflowRunsCard.tsx @@ -14,19 +14,28 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; -import { errorApiRef, useApi } from '@backstage/core-api'; +import { + configApiRef, + EmptyState, + errorApiRef, + InfoCard, + Table, + useApi, +} from '@backstage/core'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { Button, Link } from '@material-ui/core'; +import React, { useEffect } from 'react'; +import { generatePath, Link as RouterLink } from 'react-router-dom'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; import { useWorkflowRuns } from '../useWorkflowRuns'; -import React, { useEffect } from 'react'; -import { EmptyState, InfoCard, Table } from '@backstage/core'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; -import { Button, Link } from '@material-ui/core'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; const firstLine = (message: string): string => message.split('\n')[0]; export type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; branch?: string; dense?: boolean; limit?: number; @@ -34,17 +43,23 @@ export type Props = { }; export const RecentWorkflowRunsCard = ({ - entity, branch, dense = false, limit = 5, variant, }: Props) => { + const { entity } = useEntity(); + const config = useApi(configApiRef); const errorApi = useApi(errorApiRef); + // TODO: Get github hostname from metadata annotation + const hostname = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + )[0].host; const [owner, repo] = ( entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/' ).split('/'); const [{ runs = [], loading, error }] = useWorkflowRuns({ + hostname, owner, repo, branch, diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx index e7430d40ed..834fcae43c 100644 --- a/plugins/github-actions/src/components/Router.tsx +++ b/plugins/github-actions/src/components/Router.tsx @@ -15,6 +15,7 @@ */ import React from 'react'; import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Routes, Route } from 'react-router'; import { rootRouteRef, buildRouteRef } from '../plugin'; import { WorkflowRunDetails } from './WorkflowRunDetails'; @@ -22,13 +23,23 @@ import { WorkflowRunsTable } from './WorkflowRunsTable'; import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName'; import { MissingAnnotationEmptyState } from '@backstage/core'; -export const isPluginApplicableToEntity = (entity: Entity) => +export const isGithubActionsAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]); -export const Router = ({ entity }: { entity: Entity }) => - !isPluginApplicableToEntity(entity) ? ( - - ) : ( +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; +}; + +export const Router = (_props: Props) => { + const { entity } = useEntity(); + + if (!isGithubActionsAvailable(entity)) { + return ( + + ); + } + return ( ) ); +}; diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx index a2753589eb..7bcdb1f9bc 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx +++ b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; -import { Link } from '@backstage/core'; +import { configApiRef, Link, useApi } from '@backstage/core'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { Accordion, AccordionDetails, @@ -71,26 +72,6 @@ const useStyles = makeStyles(theme => ({ }, })); -const JobsList = ({ jobs, entity }: { jobs?: Jobs; entity: Entity }) => { - const classes = useStyles(); - return ( - - {jobs && - jobs.total_count > 0 && - jobs.jobs.map(job => ( - - ))} - - ); -}; - const getElapsedTime = (start: string, end: string) => { const diff = moment(moment(end || moment()).diff(moment(start))); const timeElapsed = diff.format('m [minutes] s [seconds]'); @@ -158,11 +139,36 @@ const JobListItem = ({ ); }; +const JobsList = ({ jobs, entity }: { jobs?: Jobs; entity: Entity }) => { + const classes = useStyles(); + return ( + + {jobs && + jobs.total_count > 0 && + jobs.jobs.map(job => ( + + ))} + + ); +}; + export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { + const config = useApi(configApiRef); const projectName = useProjectName(entity); + // TODO: Get github hostname from metadata annotation + const hostname = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + )[0].host; const [owner, repo] = projectName.value ? projectName.value.split('/') : []; - const details = useWorkflowRunsDetails(repo, owner); + const details = useWorkflowRunsDetails({ hostname, owner, repo }); const jobs = useWorkflowRunJobs(details.value?.jobs_url); const error = projectName.error || (projectName.value && details.error); @@ -209,8 +215,8 @@ export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { @@ -218,7 +224,7 @@ export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => { Author - {`${details.value?.head_commit.author.name} (${details.value?.head_commit.author.email})`} + {`${details.value?.head_commit.author?.name} (${details.value?.head_commit.author?.email})`} diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts b/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts index b38284d52f..d0141c27fa 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts +++ b/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts @@ -13,20 +13,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useApi, githubAuthApiRef } from '@backstage/core'; +import { useApi } from '@backstage/core'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { githubActionsApiRef } from '../../api'; -export const useWorkflowRunsDetails = (repo: string, owner: string) => { +export const useWorkflowRunsDetails = ({ + hostname, + owner, + repo, +}: { + hostname?: string; + owner: string; + repo: string; +}) => { const api = useApi(githubActionsApiRef); - const auth = useApi(githubAuthApiRef); const { id } = useParams(); const details = useAsync(async () => { - const token = await auth.getAccessToken(['repo']); return repo && owner ? api.getWorkflowRun({ - token, + hostname, owner, repo, id: parseInt(id, 10), diff --git a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx index d03c2d6be8..fe1c774dad 100644 --- a/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx +++ b/plugins/github-actions/src/components/WorkflowRunLogs/WorkflowRunLogs.tsx @@ -34,6 +34,8 @@ import { useProjectName } from '../useProjectName'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import DescriptionIcon from '@material-ui/icons/Description'; import { Entity } from '@backstage/catalog-model'; +import { configApiRef, useApi } from '@backstage/core'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); const LinePart = React.lazy(() => import('react-lazylog/build/LinePart')); @@ -107,11 +109,21 @@ export const WorkflowRunLogs = ({ runId: string; inProgress: boolean; }) => { + const config = useApi(configApiRef); const classes = useStyles(); const projectName = useProjectName(entity); + // TODO: Get github hostname from metadata annotation + const hostname = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + )[0].host; const [owner, repo] = projectName.value ? projectName.value.split('/') : []; - const jobLogs = useDownloadWorkflowRunLogs(repo, owner, runId); + const jobLogs = useDownloadWorkflowRunLogs({ + hostname, + owner, + repo, + id: runId, + }); const [open, setOpen] = React.useState(false); const handleOpen = () => { diff --git a/plugins/github-actions/src/components/WorkflowRunLogs/useDownloadWorkflowRunLogs.ts b/plugins/github-actions/src/components/WorkflowRunLogs/useDownloadWorkflowRunLogs.ts index 734f617b1f..0acf67a344 100644 --- a/plugins/github-actions/src/components/WorkflowRunLogs/useDownloadWorkflowRunLogs.ts +++ b/plugins/github-actions/src/components/WorkflowRunLogs/useDownloadWorkflowRunLogs.ts @@ -14,22 +14,26 @@ * limitations under the License. */ -import { useApi, githubAuthApiRef } from '@backstage/core'; +import { useApi } from '@backstage/core'; import { useAsync } from 'react-use'; import { githubActionsApiRef } from '../../api'; -export const useDownloadWorkflowRunLogs = ( - repo: string, - owner: string, - id: string, -) => { +export const useDownloadWorkflowRunLogs = ({ + hostname, + owner, + repo, + id, +}: { + hostname?: string; + owner: string; + repo: string; + id: string; +}) => { const api = useApi(githubActionsApiRef); - const auth = useApi(githubAuthApiRef); const details = useAsync(async () => { - const token = await auth.getAccessToken(['repo']); return repo && owner ? api.downloadJobLogsForWorkflowRun({ - token, + hostname, owner, repo, runId: parseInt(id, 10), diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index f49dfeba64..6a3b88b052 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -25,13 +25,20 @@ import { import RetryIcon from '@material-ui/icons/Replay'; import GitHubIcon from '@material-ui/icons/GitHub'; import { Link as RouterLink, generatePath } from 'react-router-dom'; -import { EmptyState, Table, TableColumn } from '@backstage/core'; +import { + EmptyState, + Table, + TableColumn, + configApiRef, + useApi, +} from '@backstage/core'; import { useWorkflowRuns } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; import { buildRouteRef } from '../../plugin'; import { useProjectName } from '../useProjectName'; import { Entity } from '@backstage/catalog-model'; +import { readGitHubIntegrationConfigs } from '@backstage/integration'; export type WorkflowRun = { id: string; @@ -162,12 +169,18 @@ export const WorkflowRunsTable = ({ entity: Entity; branch?: string; }) => { + const config = useApi(configApiRef); const { value: projectName, loading } = useProjectName(entity); + // TODO: Get github hostname from metadata annotation + const hostname = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + )[0].host; const [owner, repo] = (projectName ?? '/').split('/'); const [ { runs, ...tableProps }, { retry, setPage, setPageSize }, ] = useWorkflowRuns({ + hostname, owner, repo, branch, diff --git a/plugins/github-actions/src/components/useWorkflowRuns.ts b/plugins/github-actions/src/components/useWorkflowRuns.ts index d0be852beb..4a4ded2d94 100644 --- a/plugins/github-actions/src/components/useWorkflowRuns.ts +++ b/plugins/github-actions/src/components/useWorkflowRuns.ts @@ -17,22 +17,22 @@ import { useState } from 'react'; import { useAsyncRetry } from 'react-use'; import { WorkflowRun } from './WorkflowRunsTable/WorkflowRunsTable'; import { githubActionsApiRef } from '../api/GithubActionsApi'; -import { useApi, githubAuthApiRef, errorApiRef } from '@backstage/core'; -import { ActionsListWorkflowRunsForRepoResponseData } from '@octokit/types'; +import { useApi, errorApiRef } from '@backstage/core'; export function useWorkflowRuns({ + hostname, owner, repo, branch, initialPageSize = 5, }: { + hostname?: string; owner: string; repo: string; branch?: string; initialPageSize?: number; }) { const api = useApi(githubActionsApiRef); - const auth = useApi(githubAuthApiRef); const errorApi = useApi(errorApiRef); @@ -43,56 +43,51 @@ export function useWorkflowRuns({ const { loading, value: runs, retry, error } = useAsyncRetry< WorkflowRun[] >(async () => { - const token = await auth.getAccessToken(['repo']); return ( api // GitHub API pagination count starts from 1 .listWorkflowRuns({ - token, + hostname, owner, repo, pageSize, page: page + 1, branch, }) - .then( - ( - workflowRunsData: ActionsListWorkflowRunsForRepoResponseData, - ): WorkflowRun[] => { - setTotal(workflowRunsData.total_count); - // Transformation here - return workflowRunsData.workflow_runs.map(run => ({ - message: run.head_commit.message, - id: `${run.id}`, - onReRunClick: async () => { - try { - await api.reRunWorkflow({ - token, - owner, - repo, - runId: run.id, - }); - } catch (e) { - errorApi.post(e); - } + .then((workflowRunsData): WorkflowRun[] => { + setTotal(workflowRunsData.total_count); + // Transformation here + return workflowRunsData.workflow_runs.map(run => ({ + message: run.head_commit.message, + id: `${run.id}`, + onReRunClick: async () => { + try { + await api.reRunWorkflow({ + hostname, + owner, + repo, + runId: run.id, + }); + } catch (e) { + errorApi.post(e); + } + }, + source: { + branchName: run.head_branch, + commit: { + hash: run.head_commit.id, + url: run.head_repository.branches_url.replace( + '{/branch}', + run.head_branch, + ), }, - source: { - branchName: run.head_branch, - commit: { - hash: run.head_commit.id, - url: run.head_repository.branches_url.replace( - '{/branch}', - run.head_branch, - ), - }, - }, - status: run.status, - conclusion: run.conclusion, - url: run.url, - githubUrl: run.html_url, - })); - }, - ) + }, + status: run.status, + conclusion: run.conclusion, + url: run.url, + githubUrl: run.html_url, + })); + }) ); }, [page, pageSize, repo, owner]); diff --git a/plugins/github-actions/src/index.ts b/plugins/github-actions/src/index.ts index 24fe6fc90d..b4179a9d92 100644 --- a/plugins/github-actions/src/index.ts +++ b/plugins/github-actions/src/index.ts @@ -14,8 +14,19 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + githubActionsPlugin, + githubActionsPlugin as plugin, + EntityGithubActionsContent, + EntityLatestGithubActionRunCard, + EntityLatestGithubActionsForBranchCard, + EntityRecentGithubActionsRunsCard, +} from './plugin'; export * from './api'; -export { Router, isPluginApplicableToEntity } from './components/Router'; +export { + Router, + isGithubActionsAvailable, + isGithubActionsAvailable as isPluginApplicableToEntity, +} from './components/Router'; export * from './components/Cards'; export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName'; diff --git a/plugins/github-actions/src/plugin.test.ts b/plugins/github-actions/src/plugin.test.ts index fa1075cbc8..53bc96c77a 100644 --- a/plugins/github-actions/src/plugin.test.ts +++ b/plugins/github-actions/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { githubActionsPlugin } from './plugin'; describe('github-actions', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(githubActionsPlugin).toBeDefined(); }); }); diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts index 9e3c965d46..d296b4ad30 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -15,9 +15,13 @@ */ import { + configApiRef, createPlugin, createRouteRef, createApiFactory, + githubAuthApiRef, + createRoutableExtension, + createComponentExtension, } from '@backstage/core'; import { githubActionsApiRef, GithubActionsClient } from './api'; @@ -32,7 +36,51 @@ export const buildRouteRef = createRouteRef({ title: 'GitHub Actions Workflow Run', }); -export const plugin = createPlugin({ +export const githubActionsPlugin = createPlugin({ id: 'github-actions', - apis: [createApiFactory(githubActionsApiRef, new GithubActionsClient())], + apis: [ + createApiFactory({ + api: githubActionsApiRef, + deps: { configApi: configApiRef, githubAuthApi: githubAuthApiRef }, + factory: ({ configApi, githubAuthApi }) => + new GithubActionsClient({ configApi, githubAuthApi }), + }), + ], + routes: { + entityContent: rootRouteRef, + }, }); + +export const EntityGithubActionsContent = githubActionsPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); + +export const EntityLatestGithubActionRunCard = githubActionsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/Cards').then(m => m.LatestWorkflowRunCard), + }, + }), +); + +export const EntityLatestGithubActionsForBranchCard = githubActionsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/Cards').then(m => m.LatestWorkflowsForBranchCard), + }, + }), +); + +export const EntityRecentGithubActionsRunsCard = githubActionsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/Cards').then(m => m.RecentWorkflowRunsCard), + }, + }), +); diff --git a/plugins/gitops-profiles/CHANGELOG.md b/plugins/gitops-profiles/CHANGELOG.md index 6061a8fb31..0fa9fa24a2 100644 --- a/plugins/gitops-profiles/CHANGELOG.md +++ b/plugins/gitops-profiles/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/plugin-gitops-profiles +## 0.2.4 + +### Patch Changes + +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [54c7d02f7] + - @backstage/core@0.6.0 + - @backstage/theme@0.2.3 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] + - @backstage/core@0.5.0 + ## 0.2.2 ### Patch Changes diff --git a/plugins/gitops-profiles/dev/index.tsx b/plugins/gitops-profiles/dev/index.tsx index 812a5585d4..c164e4005d 100644 --- a/plugins/gitops-profiles/dev/index.tsx +++ b/plugins/gitops-profiles/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { gitopsProfilesPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(gitopsProfilesPlugin).render(); diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index e6a1092b47..c25628fa62 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-gitops-profiles", - "version": "0.2.2", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/core": "^0.6.0", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -42,8 +42,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/gitops-profiles/src/index.ts b/plugins/gitops-profiles/src/index.ts index d67bc6a864..876c7c3263 100644 --- a/plugins/gitops-profiles/src/index.ts +++ b/plugins/gitops-profiles/src/index.ts @@ -14,5 +14,11 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + gitopsProfilesPlugin, + gitopsProfilesPlugin as plugin, + GitopsProfilesClusterListPage, + GitopsProfilesClusterPage, + GitopsProfilesCreatePage, +} from './plugin'; export * from './api'; diff --git a/plugins/gitops-profiles/src/plugin.test.ts b/plugins/gitops-profiles/src/plugin.test.ts index 19bc49eba7..fa26902f2b 100644 --- a/plugins/gitops-profiles/src/plugin.test.ts +++ b/plugins/gitops-profiles/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { gitopsProfilesPlugin } from './plugin'; describe('gitops-profiles', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(gitopsProfilesPlugin).toBeDefined(); }); }); diff --git a/plugins/gitops-profiles/src/plugin.ts b/plugins/gitops-profiles/src/plugin.ts index 45820644f7..98a0067969 100644 --- a/plugins/gitops-profiles/src/plugin.ts +++ b/plugins/gitops-profiles/src/plugin.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createPlugin, createApiFactory } from '@backstage/core'; +import { + createPlugin, + createApiFactory, + createRoutableExtension, +} from '@backstage/core'; import ProfileCatalog from './components/ProfileCatalog'; import ClusterPage from './components/ClusterPage'; import ClusterList from './components/ClusterList'; @@ -25,7 +29,7 @@ import { } from './routes'; import { gitOpsApiRef, GitOpsRestApi } from './api'; -export const plugin = createPlugin({ +export const gitopsProfilesPlugin = createPlugin({ id: 'gitops-profiles', apis: [ createApiFactory(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')), @@ -35,4 +39,30 @@ export const plugin = createPlugin({ router.addRoute(gitOpsClusterDetailsRoute, ClusterPage); router.addRoute(gitOpsClusterCreateRoute, ProfileCatalog); }, + routes: { + listPage: gitOpsClusterListRoute, + detailsPage: gitOpsClusterDetailsRoute, + createPage: gitOpsClusterCreateRoute, + }, }); + +export const GitopsProfilesClusterListPage = gitopsProfilesPlugin.provide( + createRoutableExtension({ + component: () => import('./components/ClusterList').then(m => m.default), + mountPoint: gitOpsClusterListRoute, + }), +); + +export const GitopsProfilesClusterPage = gitopsProfilesPlugin.provide( + createRoutableExtension({ + component: () => import('./components/ClusterPage').then(m => m.default), + mountPoint: gitOpsClusterDetailsRoute, + }), +); + +export const GitopsProfilesCreatePage = gitopsProfilesPlugin.provide( + createRoutableExtension({ + component: () => import('./components/ProfileCatalog').then(m => m.default), + mountPoint: gitOpsClusterCreateRoute, + }), +); diff --git a/plugins/gitops-profiles/src/routes.ts b/plugins/gitops-profiles/src/routes.ts index f9bdefa806..28be85bd38 100644 --- a/plugins/gitops-profiles/src/routes.ts +++ b/plugins/gitops-profiles/src/routes.ts @@ -28,6 +28,7 @@ export const gitOpsClusterDetailsRoute = createRouteRef({ icon: NoIcon, path: '/gitops-cluster/:owner/:repo', title: 'GitOps Cluster details', + params: ['owner', 'repo'], }); export const gitOpsClusterCreateRoute = createRouteRef({ diff --git a/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index 537e178b6f..d6f4864a2f 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,43 @@ # @backstage/plugin-graphiql +## 0.2.7 + +### Patch Changes + +- 87b189d00: Finalized composability API migration, now exporting the plugin as `graphiqlPlugin`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [54c7d02f7] + - @backstage/core@0.6.0 + - @backstage/theme@0.2.3 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] + - @backstage/core@0.5.0 + +## 0.2.5 + +### Patch Changes + +- 5a1368ba1: Updated README +- Updated dependencies [265a7ab30] + - @backstage/core@0.4.4 + +## 0.2.4 + +### Patch Changes + +- 21f6be0f6: Patch release to force a release for the `esm.js` extension change for dynamically loading chunks + ## 0.2.3 ### Patch Changes diff --git a/plugins/graphiql/README.md b/plugins/graphiql/README.md index d2f8cafbfb..c75d8b71e3 100644 --- a/plugins/graphiql/README.md +++ b/plugins/graphiql/README.md @@ -12,53 +12,64 @@ By exposing GraphiQL as a plugin instead of a standalone app, it's possible to p Start out by installing the plugin in your Backstage app: ```bash +cd packages/app yarn add @backstage/plugin-graphiql ``` -Then add an entry to your App's `plugins.ts` to import the plugin. +```diff +# in packages/app/src/plugins.ts ++export { plugin as GraphiQL } from '@backstage/plugin-graphiql'; +``` -The plugin registers a `/graphiql` route, which you can link to from the Sidebar if desired. +```diff +# in packages/app/src/App.tsx ++import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql'; + + const AppRoutes = () => ( + ++ } /> +``` ### Adding GraphQL endpoints -For the plugin to function, you need to supply GraphQL endpoints through the GraphQLBrowse API, which is done by implementing the `GraphQLBrowseApi` exported by this plugin. +For the plugin to function, you need to supply GraphQL endpoints through the GraphQLBrowse API. This is done by implementing the `graphQlBrowseApiRef` exported by this plugin. -If all you need is a static list of endpoints, the plugin exports a `GraphQLEndpoints` class that implements the `GraphQLBrowseApi` for you. Here's and example of how you could expose two GraphQL endpoints in your App: +If all you need is a static list of endpoints, the plugin exports a `GraphQLEndpoints` class that implements the `GraphQLBrowseApi` for you. Here's an example of how you could expose two GraphQL endpoints in your App: -```tsx -import { - graphQlBrowseApiRef, - GraphQLEndpoints, -} from '@backstage/plugin-graphiql'; +```diff +# in packages/app/src/apis.ts ++import { ++ graphQlBrowseApiRef, ++ GraphQLEndpoints, ++} from '@backstage/plugin-graphiql'; -// Implement the Graph QL browse API using a static list of endpoints -const graphQlBrowseApi = GraphQLEndpoints.from([ - // Use the .create function if all you need is a static URL and headers. - GraphQLEndpoints.create({ - id: 'gitlab', - title: 'GitLab', - url: 'https://gitlab.com/api/graphql', - // Optional extra headers - headers: { Extra: 'Header' }, - }), - { - id: 'hooli-search', - title: 'Hooli Search', - // Custom fetch function, this one is equivalent to using GraphQLEndpoints.create() - // with url set to https://internal.hooli.com/search - fetcher: async (params: any) => { - return fetch('https://internal.hooli.com/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }).then(res => res.json()); - }, - }, -]); - -// ApiRegistry builder created somewhere in your App -const builder = ApiRegistry.builder(); - -// Add the instance to the API registry -builder.add(graphQlBrowseApiRef, graphQlBrowseApi); + export const apis = [ ++ createApiFactory({ ++ api: graphQlBrowseApiRef, ++ deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef }, ++ factory: ({ errorApi, githubAuthApi }) => ++ GraphQLEndpoints.from([ ++ // Use the .create function if all you need is a static URL and headers. ++ GraphQLEndpoints.create({ ++ id: 'gitlab', ++ title: 'GitLab', ++ url: 'https://gitlab.com/api/graphql', ++ // Optional extra headers ++ headers: { Extra: 'Header' }, ++ }), ++ { ++ id: 'hooli-search', ++ title: 'Hooli Search', ++ // Custom fetch function, this one is equivalent to using GraphQLEndpoints.create() ++ // with url set to https://internal.hooli.com/search ++ fetcher: async (params: any) => { ++ return fetch('https://internal.hooli.com/search', { ++ method: 'POST', ++ headers: { 'Content-Type': 'application/json' }, ++ body: JSON.stringify(params), ++ }).then(res => res.json()); ++ }, ++ } ++ ]), ++ }), ``` diff --git a/plugins/graphiql/dev/index.tsx b/plugins/graphiql/dev/index.tsx index b93995a5dc..918695e0e0 100644 --- a/plugins/graphiql/dev/index.tsx +++ b/plugins/graphiql/dev/index.tsx @@ -14,12 +14,18 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; import { githubAuthApiRef, errorApiRef } from '@backstage/core'; -import { plugin, GraphQLEndpoints, graphQlBrowseApiRef } from '../src'; +import { + graphiqlPlugin, + GraphQLEndpoints, + graphQlBrowseApiRef, + GraphiQLPage, +} from '../src'; createDevApp() - .registerPlugin(plugin) + .registerPlugin(graphiqlPlugin) .registerApi({ api: graphQlBrowseApiRef, deps: { @@ -47,4 +53,8 @@ createDevApp() ]); }, }) + .addPage({ + title: 'GraphiQL', + element: , + }) .render(); diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 0c42b824fb..9293a07dd7 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.2.3", + "version": "0.2.7", "private": false, "publishConfig": { "access": "public", @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/core": "^0.6.0", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,8 +43,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/graphiql/src/index.ts b/plugins/graphiql/src/index.ts index 4b1da14079..ed64779eba 100644 --- a/plugins/graphiql/src/index.ts +++ b/plugins/graphiql/src/index.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -export { plugin, GraphiQLPage } from './plugin'; +export { + graphiqlPlugin, + graphiqlPlugin as plugin, + GraphiQLPage, +} from './plugin'; export { GraphiQLPage as Router } from './components'; export * from './lib/api'; export * from './route-refs'; diff --git a/plugins/graphiql/src/plugin.test.ts b/plugins/graphiql/src/plugin.test.ts index 0841d3e071..3683c0be0a 100644 --- a/plugins/graphiql/src/plugin.test.ts +++ b/plugins/graphiql/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { graphiqlPlugin } from './plugin'; describe('graphiql', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(graphiqlPlugin).toBeDefined(); }); }); diff --git a/plugins/graphiql/src/plugin.ts b/plugins/graphiql/src/plugin.ts index a0bb79ed29..42969999d9 100644 --- a/plugins/graphiql/src/plugin.ts +++ b/plugins/graphiql/src/plugin.ts @@ -22,10 +22,11 @@ import { import { graphQlBrowseApiRef, GraphQLEndpoints } from './lib/api'; import { graphiQLRouteRef } from './route-refs'; -export const plugin = createPlugin({ +export const graphiqlPlugin = createPlugin({ id: 'graphiql', apis: [ - // GitLab is used as an example endpoint, but most plug + // GitLab is used as an example endpoint, but most will want to plug in + // their own instead. createApiFactory( graphQlBrowseApiRef, GraphQLEndpoints.from([ @@ -39,7 +40,7 @@ export const plugin = createPlugin({ ], }); -export const GraphiQLPage = plugin.provide( +export const GraphiQLPage = graphiqlPlugin.provide( createRoutableExtension({ component: () => import('./components').then(m => m.GraphiQLPage), mountPoint: graphiQLRouteRef, diff --git a/plugins/graphql/CHANGELOG.md b/plugins/graphql/CHANGELOG.md index 0b55e37955..4a0ac8daca 100644 --- a/plugins/graphql/CHANGELOG.md +++ b/plugins/graphql/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-graphql-backend +## 0.1.5 + +### Patch Changes + +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] + - @backstage/backend-common@0.5.0 + - @backstage/plugin-catalog-graphql@0.2.6 + ## 0.1.4 ### Patch Changes diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index d30e727867..ab12ee5278 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-graphql-backend", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.0", + "@backstage/backend-common": "^0.5.2", "@backstage/config": "^0.1.2", - "@backstage/plugin-catalog-graphql": "^0.2.4", + "@backstage/plugin-catalog-graphql": "^0.2.6", "@graphql-modules/core": "^0.7.17", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.1", + "@backstage/cli": "^0.6.0", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index 78792789cf..2199c58949 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,67 @@ # @backstage/plugin-jenkins +## 0.3.8 + +### Patch Changes + +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.3.7 + +### Patch Changes + +- 9f8faed25: Update Jenkins card with build date, duration, and rename card to "latest" build. +- Updated dependencies [a4e636c8f] +- Updated dependencies [099c5cf4f] +- Updated dependencies [a08db734c] + - @backstage/plugin-catalog@0.2.13 + +## 0.3.6 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + +## 0.3.5 + +### Patch Changes + +- feabc7f0c: Handle missing ObjectMetadataAction in Jenkins API +- Updated dependencies [f3b064e1c] +- Updated dependencies [c00488983] +- Updated dependencies [265a7ab30] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/plugin-catalog@0.2.11 + - @backstage/core@0.4.4 + ## 0.3.4 ### Patch Changes diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 5e0a701caf..154fcf913d 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -84,6 +84,5 @@ YWRtaW46MTFlYzI1NmU0Mzg1MDFjM2Y1Yzc2Yjc1MWE3ZTQ3YWY4Mw== is the base64 of user a ## Limitations -- Only works with projects that use the Git SCM -- It requires jobs to be organised into folders -- No pagination support currently - don't run this on a Jenkins with lots of builds +- Only works with organization folder jobs backed by GitHub +- No pagination support currently, limited to 50 jobs - don't run this on a Jenkins with lots of builds diff --git a/plugins/jenkins/dev/index.tsx b/plugins/jenkins/dev/index.tsx index 812a5585d4..8beb6eabc1 100644 --- a/plugins/jenkins/dev/index.tsx +++ b/plugins/jenkins/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { jenkinsPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(jenkinsPlugin).render(); diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 631e394d71..5d59d2e5ab 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.3.4", + "version": "0.3.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,14 +31,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "jenkins": "^0.28.0", + "luxon": "^1.25.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", @@ -46,8 +47,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 8dbde9160d..ed70607c27 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -63,7 +63,7 @@ export class JenkinsApi { return lastBuild; } - extractScmDetailsFromJob(jobDetails: any): any { + extractScmDetailsFromJob(jobDetails: any): any | undefined { const scmInfo = jobDetails.actions .filter( (action: any) => @@ -79,6 +79,10 @@ export class JenkinsApi { }) .pop(); + if (!scmInfo) { + return undefined; + } + const author = jobDetails.actions .filter( (action: any) => @@ -141,7 +145,7 @@ export class JenkinsApi { for (const jobDetails of folder.jobs) { const jobScmInfo = this.extractScmDetailsFromJob(jobDetails); - if (jobDetails.jobs) { + if (jobDetails?.jobs) { // skipping folders inside folders for now } else { for (const buildDetails of jobDetails.builds) { diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 637cd40d6d..d8f728bb27 100644 --- a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -47,12 +47,6 @@ const useStyles = makeStyles(theme => ({ }, })); -const Page = () => ( - - - -); - const BuildWithStepsView = () => { const { owner, repo } = useProjectSlugFromEntity(); const { branch, buildNumber } = useParams(); @@ -129,6 +123,11 @@ const BuildWithStepsView = () => {
); }; +const Page = () => ( + + + +); export default Page; export { BuildWithStepsView as BuildWithSteps }; diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index 31acedc180..447afff266 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ import React from 'react'; +import { DateTime, Duration } from 'luxon'; import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core'; import { InfoCard, StructuredMetadataTable } from '@backstage/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; @@ -30,28 +31,37 @@ const useStyles = makeStyles({ const WidgetContent = ({ loading, - lastRun, + latestRun, }: { loading?: boolean; - lastRun: any; + latestRun: any; branch: string; }) => { const classes = useStyles(); - if (loading || !lastRun) return ; - + if (loading || !latestRun) return ; + const displayDate = DateTime.fromMillis(latestRun.timestamp).toRelative(); + // TODO This works, but hard codes as minutes. Would prefer something smarter/relative. + const durationInMinutes = Math.round( + Duration.fromMillis(latestRun.duration).as('minutes'), + ); + const displayDuration = `${durationInMinutes} minute${ + durationInMinutes > 1 ? 's' : '' + }`; return ( ), - build: lastRun.fullDisplayName, - url: ( - + build: latestRun.fullDisplayName, + 'latest run': displayDate, + duration: displayDuration, + link: ( + See more on Jenkins{' '} @@ -70,10 +80,10 @@ export const LatestRunCard = ({ }) => { const { owner, repo } = useProjectSlugFromEntity(); const [{ builds, loading }] = useBuilds(owner, repo, branch); - const lastRun = builds ?? {}; + const latestRun = builds ?? {}; return ( - - + + ); }; diff --git a/plugins/jenkins/src/components/Router.tsx b/plugins/jenkins/src/components/Router.tsx index 91bcde2a8b..85f06bb0e1 100644 --- a/plugins/jenkins/src/components/Router.tsx +++ b/plugins/jenkins/src/components/Router.tsx @@ -15,6 +15,7 @@ */ import React from 'react'; import { Route, Routes } from 'react-router'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { buildRouteRef, rootRouteRef } from '../plugin'; import { DetailedViewPage } from './BuildWithStepsPage/'; import { JENKINS_ANNOTATION } from '../constants'; @@ -22,13 +23,22 @@ import { Entity } from '@backstage/catalog-model'; import { MissingAnnotationEmptyState } from '@backstage/core'; import { CITable } from './BuildsPage/lib/CITable'; -export const isPluginApplicableToEntity = (entity: Entity) => +export const isJenkinsAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[JENKINS_ANNOTATION]); -export const Router = ({ entity }: { entity: Entity }) => { - return !isPluginApplicableToEntity(entity) ? ( - - ) : ( +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; +}; + +export const Router = (_props: Props) => { + const { entity } = useEntity(); + + if (!isJenkinsAvailable(entity)) { + return ; + } + + return ( } /> } /> diff --git a/plugins/jenkins/src/components/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts index db7c7ddd55..a1ff3a1eef 100644 --- a/plugins/jenkins/src/components/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -36,17 +36,17 @@ export function useBuilds(owner: string, repo: string, branch?: string) { const { loading, value: builds, retry } = useAsyncRetry(async () => { try { - let builds; + let build; if (branch) { - builds = await api.getLastBuild(`${owner}/${repo}/${branch}`); + build = await api.getLastBuild(`${owner}/${repo}/${branch}`); } else { - builds = await api.getFolder(`${owner}/${repo}`); + build = await api.getFolder(`${owner}/${repo}`); } - const size = Array.isArray(builds) ? builds?.[0].build_num! : 1; + const size = Array.isArray(build) ? build?.[0].build_num! : 1; setTotal(size); - return builds || []; + return build || []; } catch (e) { errorApi.post(e); throw e; diff --git a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts index 1dca918990..ac2d6c7e8f 100644 --- a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts +++ b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { JENKINS_ANNOTATION } from '../constants'; export const useProjectSlugFromEntity = () => { diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index 30fa0c70a5..e434e959e8 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -14,8 +14,17 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + jenkinsPlugin, + jenkinsPlugin as plugin, + EntityJenkinsContent, + EntityLatestJenkinsRunCard, +} from './plugin'; export { LatestRunCard } from './components/Cards'; -export { Router, isPluginApplicableToEntity } from './components/Router'; +export { + Router, + isJenkinsAvailable, + isJenkinsAvailable as isPluginApplicableToEntity, +} from './components/Router'; export { JENKINS_ANNOTATION } from './constants'; export * from './api'; diff --git a/plugins/jenkins/src/plugin.test.ts b/plugins/jenkins/src/plugin.test.ts index 8c3f057ecb..0498b80b90 100644 --- a/plugins/jenkins/src/plugin.test.ts +++ b/plugins/jenkins/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { jenkinsPlugin } from './plugin'; describe('jenkins', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(jenkinsPlugin).toBeDefined(); }); }); diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 23c54743a3..85001d9bbc 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -19,6 +19,8 @@ import { createRouteRef, createApiFactory, discoveryApiRef, + createRoutableExtension, + createComponentExtension, } from '@backstage/core'; import { jenkinsApiRef, JenkinsApi } from './api'; @@ -32,7 +34,7 @@ export const buildRouteRef = createRouteRef({ title: 'Jenkins run', }); -export const plugin = createPlugin({ +export const jenkinsPlugin = createPlugin({ id: 'jenkins', apis: [ createApiFactory({ @@ -41,4 +43,21 @@ export const plugin = createPlugin({ factory: ({ discoveryApi }) => new JenkinsApi({ discoveryApi }), }), ], + routes: { + entityContent: rootRouteRef, + }, }); + +export const EntityJenkinsContent = jenkinsPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); +export const EntityLatestJenkinsRunCard = jenkinsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/Cards').then(m => m.LatestRunCard), + }, + }), +); diff --git a/plugins/kafka-backend/.eslintrc.js b/plugins/kafka-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/kafka-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/kafka-backend/CHANGELOG.md b/plugins/kafka-backend/CHANGELOG.md new file mode 100644 index 0000000000..1e4701af16 --- /dev/null +++ b/plugins/kafka-backend/CHANGELOG.md @@ -0,0 +1,53 @@ +# @backstage/plugin-kafka-backend + +## 0.2.0 + +### Minor Changes + +- 234e7d985: Added support for multiple Kafka clusters and multiple consumers per component. + Note that this introduces several breaking changes. + + 1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way: + + ```diff + kafka: + clientId: backstage + - brokers: + - - localhost:9092 + + clusters: + + - name: prod + + brokers: + + - localhost:9092 + ``` + + 2. Configuration of services has changed as well to support multiple clusters: + + ```diff + annotations: + - kafka.apache.org/consumer-groups: consumer + + kafka.apache.org/consumer-groups: prod/consumer + ``` + + 3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path: + `/consumers/${clusterId}/${consumerGroup}/offsets` + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 diff --git a/plugins/kafka-backend/README.md b/plugins/kafka-backend/README.md new file mode 100644 index 0000000000..13078ea816 --- /dev/null +++ b/plugins/kafka-backend/README.md @@ -0,0 +1,30 @@ +# Kafka Backend + +This is the backend part of the Kafka plugin. It responds to Kafka requests from the frontend. + +## Configuration + +This configures how to connect to the brokers in your Kafka cluster. + +### clientId + +The name of the client to use when connecting to the cluster. + +### brokers + +A list of the brokers' host names and ports to connect to. + +### SSL (optional) + +Configure TLS connection to the Kafka cluster. The options are passed directly to [tls.connect] and used to create the TLS secure context. Normally these would include `key` and `cert`. + +Example: + +```yaml +kafka: + clientId: backstage + clusters: + name: prod + brokers: + - localhost:9092 +``` diff --git a/plugins/kafka-backend/config.d.ts b/plugins/kafka-backend/config.d.ts new file mode 100644 index 0000000000..87254b152c --- /dev/null +++ b/plugins/kafka-backend/config.d.ts @@ -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. + */ +export interface Config { + kafka?: { + /** + * Client ID used to Backstage uses to identify when connecting to the Kafka cluster. + */ + clientId: string; + clusters: { + name: string; + /** + * List of brokers in the Kafka cluster to connect to. + */ + brokers: string[]; + /** + * Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect. + * See https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options + */ + ssl?: { + ca: string[]; + /** @visibility secret */ + key: string; + cert: string; + }; + }[]; + }; +} diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json new file mode 100644 index 0000000000..1eaa1d8316 --- /dev/null +++ b/plugins/kafka-backend/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/plugin-kafka-backend", + "version": "0.2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/kafka-backend" + }, + "keywords": [ + "backstage", + "kafka" + ], + "configSchema": "config.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": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/config": "^0.1.2", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "express-promise-router": "^3.0.3", + "kafkajs": "^1.16.0-beta.6", + "lodash": "^4.17.15", + "winston": "^3.2.1" + }, + "devDependencies": { + "@backstage/cli": "^0.6.0", + "@types/jest-when": "^2.7.2", + "@types/lodash": "^4.14.151", + "jest-when": "^3.1.0", + "supertest": "^4.0.2" + }, + "files": [ + "dist", + "schema.d.ts" + ] +} diff --git a/plugins/kafka-backend/src/config/ClusterReader.test.ts b/plugins/kafka-backend/src/config/ClusterReader.test.ts new file mode 100644 index 0000000000..0529684425 --- /dev/null +++ b/plugins/kafka-backend/src/config/ClusterReader.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 '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { getClusterDetails } from './ClusterReader'; + +describe('getClusterDetails', () => { + it('empty clusters return empty cluster details', async () => { + const config = new ConfigReader({ clusters: [] }); + + const result = getClusterDetails(config.getConfigArray('clusters')); + + expect(result).toStrictEqual([]); + }); + + it('two clusters return two cluster details', async () => { + const config = new ConfigReader({ + clusters: [ + { name: 'cluster1', brokers: ['a', 'b'] }, + { name: 'cluster2', brokers: ['d'] }, + ], + }); + + const result = getClusterDetails(config.getConfigArray('clusters')); + + expect(result).toStrictEqual([ + { name: 'cluster1', brokers: ['a', 'b'] }, + { name: 'cluster2', brokers: ['d'] }, + ]); + }); +}); diff --git a/plugins/kafka-backend/src/config/ClusterReader.ts b/plugins/kafka-backend/src/config/ClusterReader.ts new file mode 100644 index 0000000000..a4e6a4e4fe --- /dev/null +++ b/plugins/kafka-backend/src/config/ClusterReader.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 { Config } from '@backstage/config'; +import { ConnectionOptions } from 'tls'; +import { ClusterDetails } from '../types/types'; + +export function getClusterDetails(config: Config[]): ClusterDetails[] { + return config.map(clusterConfig => { + const clusterDetails = { + name: clusterConfig.getString('name'), + brokers: clusterConfig.getStringArray('brokers'), + }; + const sslConfig = clusterConfig.getOptional('kafka.ssl'); + if (sslConfig) { + return { + ...clusterDetails, + ssl: sslConfig as ConnectionOptions, + }; + } + + return clusterDetails; + }); +} diff --git a/plugins/kafka-backend/src/index.ts b/plugins/kafka-backend/src/index.ts new file mode 100644 index 0000000000..bf496e05bf --- /dev/null +++ b/plugins/kafka-backend/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 { createRouter } from './service/router'; diff --git a/plugins/kafka-backend/src/service/KafkaApi.ts b/plugins/kafka-backend/src/service/KafkaApi.ts new file mode 100644 index 0000000000..1eade40bf4 --- /dev/null +++ b/plugins/kafka-backend/src/service/KafkaApi.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Kafka, SeekEntry } from 'kafkajs'; +import { Logger } from 'winston'; +import { ConnectionOptions } from 'tls'; + +export type PartitionOffset = { + id: number; + offset: string; +}; + +export type TopicOffset = { + topic: string; + partitions: PartitionOffset[]; +}; + +export type Options = { + clientId: string; + brokers: string[]; + ssl?: ConnectionOptions; + logger: Logger; +}; + +export interface KafkaApi { + fetchTopicOffsets(topic: string): Promise>; + fetchGroupOffsets(groupId: string): Promise>; +} + +export class KafkaJsApiImpl implements KafkaApi { + private readonly kafka: Kafka; + private readonly logger: Logger; + + constructor(options: Options) { + options.logger.debug( + `creating kafka client with clientId=${options.clientId} and brokers=${options.brokers}`, + ); + + this.kafka = new Kafka(options); + this.logger = options.logger; + } + + async fetchTopicOffsets(topic: string): Promise> { + this.logger.debug(`fetching topic offsets for ${topic}`); + + const admin = this.kafka.admin(); + await admin.connect(); + + try { + return KafkaJsApiImpl.toPartitionOffsets( + await admin.fetchTopicOffsets(topic), + ); + } finally { + await admin.disconnect(); + } + } + + async fetchGroupOffsets(groupId: string): Promise> { + this.logger.debug(`fetching consumer group offsets for ${groupId}`); + + const admin = this.kafka.admin(); + await admin.connect(); + + try { + const groupOffsets = await admin.fetchOffsets({ groupId }); + + return groupOffsets.map(topicOffset => ({ + topic: topicOffset.topic, + partitions: KafkaJsApiImpl.toPartitionOffsets(topicOffset.partitions), + })); + } finally { + await admin.disconnect(); + } + } + + private static toPartitionOffsets( + result: Array, + ): Array { + return result.map(seekEntry => ({ + id: seekEntry.partition, + offset: seekEntry.offset, + })); + } +} diff --git a/plugins/kafka-backend/src/service/router.test.ts b/plugins/kafka-backend/src/service/router.test.ts new file mode 100644 index 0000000000..42cd92ddfb --- /dev/null +++ b/plugins/kafka-backend/src/service/router.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import request from 'supertest'; +import express from 'express'; +import { makeRouter, ClusterApi } from './router'; +import { getVoidLogger } from '@backstage/backend-common'; +import { KafkaApi } from './KafkaApi'; +import { when } from 'jest-when'; + +describe('router', () => { + let app: express.Express; + let apis: ClusterApi[]; + let devKafkaApi: jest.Mocked; + let prodKafkaApi: jest.Mocked; + + beforeAll(async () => { + devKafkaApi = { + fetchTopicOffsets: jest.fn(), + fetchGroupOffsets: jest.fn(), + }; + + prodKafkaApi = { + fetchTopicOffsets: jest.fn(), + fetchGroupOffsets: jest.fn(), + }; + + apis = [ + { name: 'dev', api: devKafkaApi }, + { name: 'prod', api: prodKafkaApi }, + ]; + + const router = makeRouter(getVoidLogger(), apis); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('get /consumers/clusterId/:consumerId/offsets', () => { + it('returns topic and group offsets', async () => { + const topic1Offsets = [ + { id: 1, offset: '500' }, + { id: 2, offset: '1000' }, + ]; + const topic2Offsets = [{ id: 1, offset: '456' }]; + + const groupOffsets = [ + { + topic: 'topic1', + partitions: [ + { id: 1, offset: '100' }, + { id: 2, offset: '213' }, + ], + }, + { + topic: 'topic2', + partitions: [{ id: 1, offset: '456' }], + }, + ]; + when(prodKafkaApi.fetchTopicOffsets) + .calledWith('topic1') + .mockResolvedValue(topic1Offsets); + when(prodKafkaApi.fetchTopicOffsets) + .calledWith('topic2') + .mockResolvedValue(topic2Offsets); + when(prodKafkaApi.fetchGroupOffsets) + .calledWith('hey') + .mockResolvedValue(groupOffsets); + + const response = await request(app).get('/consumers/prod/hey/offsets'); + + expect(response.status).toEqual(200); + expect(response.body.consumerId).toEqual('hey'); + // Note the Set comparison here since there's no guarantee on the order of the elements in the list. + expect(new Set(response.body.offsets)).toStrictEqual( + new Set([ + { + topic: 'topic1', + partitionId: 1, + groupOffset: '100', + topicOffset: '500', + }, + { + topic: 'topic1', + partitionId: 2, + groupOffset: '213', + topicOffset: '1000', + }, + { + topic: 'topic2', + partitionId: 1, + groupOffset: '456', + topicOffset: '456', + }, + ]), + ); + }); + + it('handles internal error correctly', async () => { + prodKafkaApi.fetchGroupOffsets.mockRejectedValue(Error('oh no')); + + const response = await request(app).get('/consumers/prod/hey/offsets'); + + expect(response.status).toEqual(500); + }); + + it('uses correct kafka cluster', async () => { + const topic1ProdOffsets = [{ id: 1, offset: '500' }]; + const topic1DevOffsets = [{ id: 1, offset: '1234' }]; + const groupProdOffsets = [ + { + topic: 'topic1', + partitions: [{ id: 1, offset: '100' }], + }, + ]; + const groupDevOffsets = [ + { + topic: 'topic1', + partitions: [{ id: 1, offset: '567' }], + }, + ]; + when(prodKafkaApi.fetchTopicOffsets) + .calledWith('topic1') + .mockResolvedValue(topic1ProdOffsets); + when(prodKafkaApi.fetchGroupOffsets) + .calledWith('hey') + .mockResolvedValue(groupProdOffsets); + when(devKafkaApi.fetchTopicOffsets) + .calledWith('topic1') + .mockResolvedValue(topic1DevOffsets); + when(devKafkaApi.fetchGroupOffsets) + .calledWith('hey') + .mockResolvedValue(groupDevOffsets); + + const prodResponse = await request(app).get( + '/consumers/prod/hey/offsets', + ); + const devResponse = await request(app).get('/consumers/dev/hey/offsets'); + + expect(prodResponse.status).toEqual(200); + expect(prodResponse.body.consumerId).toEqual('hey'); + expect(new Set(prodResponse.body.offsets)).toStrictEqual( + new Set([ + { + topic: 'topic1', + partitionId: 1, + groupOffset: '100', + topicOffset: '500', + }, + ]), + ); + expect(devResponse.status).toEqual(200); + expect(devResponse.body.consumerId).toEqual('hey'); + expect(new Set(devResponse.body.offsets)).toStrictEqual( + new Set([ + { + topic: 'topic1', + partitionId: 1, + groupOffset: '567', + topicOffset: '1234', + }, + ]), + ); + }); + }); +}); diff --git a/plugins/kafka-backend/src/service/router.ts b/plugins/kafka-backend/src/service/router.ts new file mode 100644 index 0000000000..465347ed8f --- /dev/null +++ b/plugins/kafka-backend/src/service/router.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 express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { Config } from '@backstage/config'; +import { KafkaApi, KafkaJsApiImpl } from './KafkaApi'; +import _ from 'lodash'; +import { getClusterDetails } from '../config/ClusterReader'; + +export interface RouterOptions { + logger: Logger; + config: Config; +} + +export interface ClusterApi { + name: string; + api: KafkaApi; +} + +export const makeRouter = ( + logger: Logger, + kafkaApis: ClusterApi[], +): express.Router => { + const router = Router(); + router.use(express.json()); + + const kafkaApiByClusterName = _.keyBy(kafkaApis, item => item.name); + + router.get('/consumers/:clusterId/:consumerId/offsets', async (req, res) => { + const clusterId = req.params.clusterId; + const consumerId = req.params.consumerId; + + logger.info( + `Fetch consumer group ${consumerId} offsets from cluster ${clusterId}`, + ); + + const kafkaApi = kafkaApiByClusterName[clusterId]; + + const groupOffsets = await kafkaApi.api.fetchGroupOffsets(consumerId); + + const groupWithTopicOffsets = await Promise.all( + groupOffsets.map(async ({ topic, partitions }) => { + const topicOffsets = _.keyBy( + await kafkaApi.api.fetchTopicOffsets(topic), + partition => partition.id, + ); + + return partitions.map(partition => ({ + topic: topic, + partitionId: partition.id, + groupOffset: partition.offset, + topicOffset: topicOffsets[partition.id].offset, + })); + }), + ); + res.send({ consumerId, offsets: groupWithTopicOffsets.flat() }); + }); + + return router; +}; + +export async function createRouter( + options: RouterOptions, +): Promise { + const logger = options.logger; + + logger.info('Initializing Kafka backend'); + + const clientId = options.config.getString('kafka.clientId'); + + const clusters = getClusterDetails( + options.config.getConfigArray('kafka.clusters'), + ); + + const kafkaApis = clusters.map(cluster => ({ + name: cluster.name, + api: new KafkaJsApiImpl({ clientId, logger, ...cluster }), + })); + + return makeRouter(logger, kafkaApis); +} diff --git a/plugins/api-docs/src/components/EntityLink/index.ts b/plugins/kafka-backend/src/setupTests.ts similarity index 93% rename from plugins/api-docs/src/components/EntityLink/index.ts rename to plugins/kafka-backend/src/setupTests.ts index 44875e4634..ba33cf996b 100644 --- a/plugins/api-docs/src/components/EntityLink/index.ts +++ b/plugins/kafka-backend/src/setupTests.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { EntityLink } from './EntityLink'; +export {}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts b/plugins/kafka-backend/src/types/types.ts similarity index 80% rename from plugins/scaffolder-backend/src/scaffolder/stages/types.ts rename to plugins/kafka-backend/src/types/types.ts index 56111337fe..5f70998dd8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/types.ts +++ b/plugins/kafka-backend/src/types/types.ts @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type RemoteProtocol = - | 'file' - | 'github' - | 'gitlab' - | 'gitlab/api' - | 'azure/api' - | 'bitbucket'; + +import { ConnectionOptions } from 'tls'; + +export interface ClusterDetails { + name: string; + brokers: string[]; + ssl?: ConnectionOptions; +} diff --git a/plugins/kafka/.eslintrc.js b/plugins/kafka/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/kafka/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/kafka/CHANGELOG.md b/plugins/kafka/CHANGELOG.md new file mode 100644 index 0000000000..2895288b56 --- /dev/null +++ b/plugins/kafka/CHANGELOG.md @@ -0,0 +1,76 @@ +# @backstage/plugin-kafka + +## 0.2.1 + +### Patch Changes + +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.2.0 + +### Minor Changes + +- 234e7d985: Added support for multiple Kafka clusters and multiple consumers per component. + Note that this introduces several breaking changes. + + 1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way: + + ```diff + kafka: + clientId: backstage + - brokers: + - - localhost:9092 + + clusters: + + - name: prod + + brokers: + + - localhost:9092 + ``` + + 2. Configuration of services has changed as well to support multiple clusters: + + ```diff + annotations: + - kafka.apache.org/consumer-groups: consumer + + kafka.apache.org/consumer-groups: prod/consumer + ``` + + 3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path: + `/consumers/${clusterId}/${consumerGroup}/offsets` + +### Patch Changes + +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/plugin-catalog@0.2.14 + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 diff --git a/plugins/kafka/README.md b/plugins/kafka/README.md new file mode 100644 index 0000000000..c41c328741 --- /dev/null +++ b/plugins/kafka/README.md @@ -0,0 +1,89 @@ +# Kafka Plugin + + + +## Setup + +1. Run: + +```bash +yarn add @backstage/plugin-kafka @backstage/plugin-kafka-backend +``` + +2. Add the plugin backend: + +In a new file named `kafka.js` under `backend/src/plugins`: + +```js +import { createRouter } from '@backstage/plugin-kafka-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment) { + return await createRouter({ logger, config }); +} +``` + +And then add to `packages/backend/src/index.ts`: + +```js +async function main() { + // ... + const kafkaEnv = useHotMemoize(module, () => createEnv('kafka')); + // ... + + const apiRouter = Router(); + // ... + apiRouter.use('/kafka', await kafka(kafkaEnv)); + // ... +``` + +3. Add the plugin frontend to `packages/app/src/plugin.ts`: + +```js +export { plugin as Kafka } from '@backstage/plugin-kafka'; +``` + +4. Register the plugin frontend router in `packages/app/src/components/catalog/EntityPage.tsx`: + +```jsx +import { Router as KafkaRouter } from '@backstage/plugin-kafka'; + +// Then, somewhere inside + +} +/>; +``` + +5. Add broker configs for the backend in your `app-config.yaml`: + +```yaml +kafka: + clientId: backstage + clusters: + - name: cluster-name + brokers: + - localhost:9092 +``` + +6. Add `kafka.apache.org/consumer-groups` annotation to your services: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + # ... + annotations: + kafka.apache.org/consumer-groups: cluster-name/consumer-group-name +spec: + type: service +``` + +## Features + +- List topics offsets and consumer group offsets for configured services. diff --git a/plugins/kafka/dev/index.tsx b/plugins/kafka/dev/index.tsx new file mode 100644 index 0000000000..5506d47026 --- /dev/null +++ b/plugins/kafka/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 { kafkaPlugin } from '../src/plugin'; + +createDevApp().registerPlugin(kafkaPlugin).render(); diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json new file mode 100644 index 0000000000..5d6454ed60 --- /dev/null +++ b/plugins/kafka/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-kafka", + "version": "0.2.1", + "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.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", + "@backstage/test-utils": "^0.1.6", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/react-hooks": "^3.4.2", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "jest-when": "^3.1.0", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/kafka/src/Router.tsx b/plugins/kafka/src/Router.tsx new file mode 100644 index 0000000000..13503fefe8 --- /dev/null +++ b/plugins/kafka/src/Router.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 { Entity } from '@backstage/catalog-model'; +import React from 'react'; +import { Route, Routes } from 'react-router'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { rootCatalogKafkaRouteRef } from './plugin'; +import { KAFKA_CONSUMER_GROUP_ANNOTATION } from './constants'; +import { KafkaTopicsForConsumer } from './components/ConsumerGroupOffsets/ConsumerGroupOffsets'; +import { MissingAnnotationEmptyState } from '@backstage/core'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[KAFKA_CONSUMER_GROUP_ANNOTATION]); + +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; +}; + +export const Router = (_props: Props) => { + const { entity } = useEntity(); + + if (!isPluginApplicableToEntity(entity)) { + return ( + + ); + } + + return ( + + } + /> + + ); +}; diff --git a/plugins/kafka/src/api/KafkaBackendClient.ts b/plugins/kafka/src/api/KafkaBackendClient.ts new file mode 100644 index 0000000000..8166da4601 --- /dev/null +++ b/plugins/kafka/src/api/KafkaBackendClient.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 { DiscoveryApi } from '@backstage/core'; +import { KafkaApi, ConsumerGroupOffsetsResponse } from './types'; + +export class KafkaBackendClient implements KafkaApi { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = options.discoveryApi; + } + + private async internalGet(path: string): Promise { + const url = `${await this.discoveryApi.getBaseUrl('kafka')}${path}`; + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + 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 getConsumerGroupOffsets( + clusterId: string, + consumerGroup: string, + ): Promise { + return await this.internalGet( + `/consumers/${clusterId}/${consumerGroup}/offsets`, + ); + } +} diff --git a/plugins/kafka/src/api/types.ts b/plugins/kafka/src/api/types.ts new file mode 100644 index 0000000000..c4307d5c1f --- /dev/null +++ b/plugins/kafka/src/api/types.ts @@ -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 { createApiRef } from '@backstage/core'; + +export const kafkaApiRef = createApiRef({ + id: 'plugin.kafka.service', + description: + 'Used by the Kafka plugin to make requests to accompanying backend', +}); + +export type ConsumerGroupOffsetsResponse = { + consumerId: string; + offsets: { + topic: string; + partitionId: number; + topicOffset: string; + groupOffset: string; + }[]; +}; + +export interface KafkaApi { + getConsumerGroupOffsets( + clusterId: string, + consumerGroup: string, + ): Promise; +} diff --git a/plugins/kafka/src/assets/screenshot-1.png b/plugins/kafka/src/assets/screenshot-1.png new file mode 100644 index 0000000000..f10a8918cc Binary files /dev/null and b/plugins/kafka/src/assets/screenshot-1.png differ diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.test.tsx new file mode 100644 index 0000000000..9cedc5e135 --- /dev/null +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.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 { renderInTestApp } from '@backstage/test-utils'; +import { ConsumerGroupOffsets } from './ConsumerGroupOffsets'; +import * as data from './__fixtures__/consumer-group-offsets.json'; +import { ConsumerGroupOffsetsResponse } from '../../api/types'; + +const consumerGroupOffsets = data as ConsumerGroupOffsetsResponse; + +describe('ConsumerGroupOffsets', () => { + it('should render consumer group table', async () => { + const rendered = await renderInTestApp( + {}} + />, + ); + + expect(rendered.getByText(/consumer/)).toBeInTheDocument(); + expect(rendered.getByText('topic1')).toBeInTheDocument(); + expect(rendered.getByText('topic2')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.tsx new file mode 100644 index 0000000000..7928393526 --- /dev/null +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/ConsumerGroupOffsets.tsx @@ -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 { Table, TableColumn } from '@backstage/core'; +import { Box, Grid, Typography } from '@material-ui/core'; +import RetryIcon from '@material-ui/icons/Replay'; +import React from 'react'; +import { useConsumerGroupsOffsetsForEntity } from './useConsumerGroupsOffsetsForEntity'; + +export type TopicPartitionInfo = { + topic: string; + partitionId: number; + topicOffset: string; + groupOffset: string; +}; + +const generatedColumns: TableColumn[] = [ + { + title: 'Topic', + field: 'topic', + highlight: true, + render: (row: Partial) => { + return <>{row.topic ?? ''}; + }, + }, + { + title: 'Partition', + field: 'partitionId', + render: (row: Partial) => { + return <>{row.partitionId ?? ''}; + }, + }, + { + title: 'Topic Offset', + field: 'topicOffset', + render: (row: Partial) => { + return <>{row.topicOffset ?? ''}; + }, + }, + { + title: 'Group Offset', + field: 'groupOffset', + render: (row: Partial) => { + return <>{row.groupOffset ?? ''}; + }, + }, +]; + +type Props = { + loading: boolean; + retry: () => void; + clusterId: string; + consumerGroup: string; + topics?: TopicPartitionInfo[]; +}; + +export const ConsumerGroupOffsets = ({ + loading, + topics, + clusterId, + consumerGroup, + retry, +}: Props) => { + return ( +
, + tooltip: 'Refresh Data', + isFreeAction: true, + onClick: () => retry(), + }, + ]} + data={topics ?? []} + title={ + + + Consumed Topics for {consumerGroup} ({clusterId}) + + + } + columns={generatedColumns} + /> + ); +}; + +export const KafkaTopicsForConsumer = () => { + const [tableProps, { retry }] = useConsumerGroupsOffsetsForEntity(); + return ( + + {tableProps.consumerGroupsTopics?.map(consumerGroup => ( + + ))} + + ); +}; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/__fixtures__/consumer-group-offsets.json b/plugins/kafka/src/components/ConsumerGroupOffsets/__fixtures__/consumer-group-offsets.json new file mode 100644 index 0000000000..ed4b81c066 --- /dev/null +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/__fixtures__/consumer-group-offsets.json @@ -0,0 +1,17 @@ +{ + "consumerId": "consumer", + "offsets": [ + { + "topic": "topic1", + "partitionId": 1, + "topicOffset": "100", + "groupOffset": "50" + }, + { + "topic": "topic2", + "partitionId": 1, + "topicOffset": "2340", + "groupOffset": "1234" + } + ] +} diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx new file mode 100644 index 0000000000..a28d1af15d --- /dev/null +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { EntityContext } from '@backstage/plugin-catalog-react'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { PropsWithChildren } from 'react'; +import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity'; + +describe('useConsumerGroupOffsets', () => { + let entity: Entity; + + const wrapper = ({ children }: PropsWithChildren<{}>) => { + return ( + + {children} + + ); + }; + + const subject = () => renderHook(useConsumerGroupsForEntity, { wrapper }); + + it('returns correct cluster and consumer group for annotation', async () => { + entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'kafka.apache.org/consumer-groups': 'prod/consumer', + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + const { result } = subject(); + expect(result.current).toStrictEqual([ + { + clusterId: 'prod', + consumerGroup: 'consumer', + }, + ]); + }); + + it('returns correct cluster and consumer group for multiple consumers', async () => { + entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'kafka.apache.org/consumer-groups': + 'prod/consumer,dev/another-consumer', + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + const { result } = subject(); + expect(result.current).toStrictEqual([ + { clusterId: 'prod', consumerGroup: 'consumer' }, + { + clusterId: 'dev', + consumerGroup: 'another-consumer', + }, + ]); + }); + + it('returns correct cluster and consumer group for annotation with extra spaces', async () => { + entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'kafka.apache.org/consumer-groups': + ' prod/consumer , dev/another-consumer ', + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + const { result } = subject(); + expect(result.current).toStrictEqual([ + { clusterId: 'prod', consumerGroup: 'consumer' }, + { + clusterId: 'dev', + consumerGroup: 'another-consumer', + }, + ]); + }); + + it('fails on missing cluster', async () => { + entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'kafka.apache.org/consumer-groups': 'dev/another,consumer', + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + const { result } = subject(); + expect(() => result.current).toThrowError(); + expect(result.error).toStrictEqual( + new Error( + `Failed to parse kafka consumer group annotation: got "dev/another,consumer"`, + ), + ); + }); +}); diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts new file mode 100644 index 0000000000..11155dca10 --- /dev/null +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useMemo } from 'react'; +import { KAFKA_CONSUMER_GROUP_ANNOTATION } from '../../constants'; + +export const useConsumerGroupsForEntity = () => { + const { entity } = useEntity(); + const annotation = + entity.metadata.annotations?.[KAFKA_CONSUMER_GROUP_ANNOTATION] ?? ''; + + const consumerList = useMemo(() => { + return annotation.split(',').map(consumer => { + const [clusterId, consumerGroup] = consumer.split('/'); + + if (!clusterId || !consumerGroup) { + throw new Error( + `Failed to parse kafka consumer group annotation: got "${annotation}"`, + ); + } + return { + clusterId: clusterId.trim(), + consumerGroup: consumerGroup.trim(), + }; + }); + }, [annotation]); + + return consumerList; +}; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx new file mode 100644 index 0000000000..9609654a90 --- /dev/null +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { renderHook } from '@testing-library/react-hooks'; +import { when } from 'jest-when'; +import React, { PropsWithChildren } from 'react'; +import { + ConsumerGroupOffsetsResponse, + KafkaApi, + kafkaApiRef, +} from '../../api/types'; +import { useConsumerGroupsOffsetsForEntity } from './useConsumerGroupsOffsetsForEntity'; +import * as data from './__fixtures__/consumer-group-offsets.json'; + +const consumerGroupOffsets = data as ConsumerGroupOffsetsResponse; + +const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), +}; + +const mockKafkaApi: jest.Mocked = { + getConsumerGroupOffsets: jest.fn(), +}; + +describe('useConsumerGroupOffsets', () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + annotations: { + 'kafka.apache.org/consumer-groups': `prod/${consumerGroupOffsets.consumerId}`, + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + + const wrapper = ({ children }: PropsWithChildren<{}>) => { + return ( + + + {children} + + + ); + }; + + const subject = () => + renderHook(useConsumerGroupsOffsetsForEntity, { wrapper }); + + it('returns correct consumer group for annotation', async () => { + mockKafkaApi.getConsumerGroupOffsets.mockResolvedValue( + consumerGroupOffsets, + ); + when(mockKafkaApi.getConsumerGroupOffsets) + .calledWith('prod', consumerGroupOffsets.consumerId) + .mockResolvedValue(consumerGroupOffsets); + + const { result, waitForNextUpdate } = subject(); + await waitForNextUpdate(); + const [tableProps] = result.current; + + expect(tableProps.consumerGroupsTopics).toStrictEqual([ + { + clusterId: 'prod', + consumerGroup: consumerGroupOffsets.consumerId, + topics: consumerGroupOffsets.offsets, + }, + ]); + }); + + it('posts an error to the error api', async () => { + const error = new Error('error!'); + mockKafkaApi.getConsumerGroupOffsets.mockRejectedValueOnce(error); + + const { waitForNextUpdate } = subject(); + await waitForNextUpdate(); + + expect(mockErrorApi.post).toHaveBeenCalledWith(error); + }); +}); diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.ts b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.ts new file mode 100644 index 0000000000..8cb30040e9 --- /dev/null +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.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 { errorApiRef, useApi } from '@backstage/core'; +import { useAsyncRetry } from 'react-use'; +import { kafkaApiRef } from '../../api/types'; +import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity'; + +export const useConsumerGroupsOffsetsForEntity = () => { + const consumers = useConsumerGroupsForEntity(); + const api = useApi(kafkaApiRef); + const errorApi = useApi(errorApiRef); + + const { + loading, + value: consumerGroupsTopics, + retry, + } = useAsyncRetry(async () => { + try { + return await Promise.all( + consumers.map(async ({ clusterId, consumerGroup }) => { + const response = await api.getConsumerGroupOffsets( + clusterId, + consumerGroup, + ); + return { clusterId, consumerGroup, topics: response.offsets }; + }), + ); + } catch (e) { + errorApi.post(e); + throw e; + } + }, [consumers, api, errorApi]); + + return [ + { + loading, + consumerGroupsTopics, + }, + { + retry, + }, + ] as const; +}; diff --git a/plugins/kafka/src/constants.ts b/plugins/kafka/src/constants.ts new file mode 100644 index 0000000000..652911a91f --- /dev/null +++ b/plugins/kafka/src/constants.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 const KAFKA_CONSUMER_GROUP_ANNOTATION = + 'kafka.apache.org/consumer-groups'; diff --git a/plugins/kafka/src/index.ts b/plugins/kafka/src/index.ts new file mode 100644 index 0000000000..65b077756b --- /dev/null +++ b/plugins/kafka/src/index.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. + */ +export { + kafkaPlugin, + kafkaPlugin as plugin, + EntityKafkaContent, +} from './plugin'; +export { KAFKA_CONSUMER_GROUP_ANNOTATION } from './constants'; +export { + Router, + isPluginApplicableToEntity, + isPluginApplicableToEntity as isKafkaAvailable, +} from './Router'; diff --git a/plugins/kafka/src/plugin.test.ts b/plugins/kafka/src/plugin.test.ts new file mode 100644 index 0000000000..227f88b39f --- /dev/null +++ b/plugins/kafka/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 { kafkaPlugin } from './plugin'; + +describe('kafka', () => { + it('should export plugin', () => { + expect(kafkaPlugin).toBeDefined(); + }); +}); diff --git a/plugins/kafka/src/plugin.ts b/plugins/kafka/src/plugin.ts new file mode 100644 index 0000000000..832099a30c --- /dev/null +++ b/plugins/kafka/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, + createRoutableExtension, + createRouteRef, + discoveryApiRef, +} from '@backstage/core'; +import { KafkaBackendClient } from './api/KafkaBackendClient'; +import { kafkaApiRef } from './api/types'; + +export const rootCatalogKafkaRouteRef = createRouteRef({ + path: '*', + title: 'Kafka', +}); + +export const kafkaPlugin = createPlugin({ + id: 'kafka', + apis: [ + createApiFactory({ + api: kafkaApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new KafkaBackendClient({ discoveryApi }), + }), + ], + routes: { + entityContent: rootCatalogKafkaRouteRef, + }, +}); + +export const EntityKafkaContent = kafkaPlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.Router), + mountPoint: rootCatalogKafkaRouteRef, + }), +); diff --git a/plugins/kafka/src/setupTests.ts b/plugins/kafka/src/setupTests.ts new file mode 100644 index 0000000000..43b8421558 --- /dev/null +++ b/plugins/kafka/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 9b247c174f..08a3a66657 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,41 @@ # @backstage/plugin-kubernetes-backend +## 0.2.6 + +### Patch Changes + +- 681111228: Add AWS auth provider for Kubernetes +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + +## 0.2.5 + +### Patch Changes + +- d54857099: Support HTTP 400 Bad Request from Kubernetes API +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + +## 0.2.4 + +### Patch Changes + +- 5a9a7e7c2: Revamped Kubernetes UI and added error reporting/detection +- Updated dependencies [f3b064e1c] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/backend-common@0.4.3 + ## 0.2.3 ### Patch Changes diff --git a/plugins/kubernetes-backend/README.md b/plugins/kubernetes-backend/README.md index a9fdc14707..b724babca3 100644 --- a/plugins/kubernetes-backend/README.md +++ b/plugins/kubernetes-backend/README.md @@ -1,83 +1,19 @@ # Kubernetes Backend -WORK IN PROGRESS +This is the backend part of the Kubernetes plugin for Backstage. It is called by and responds to requests from the frontend [`@backstage/plugin-kubernetes`](https://github.com/backstage/backstage/tree/master/plugins/kubernetes) plugin. -This is the backend part of the Kubernetes plugin. +It directly interfaces with the Kubernetes API control plane to obtain information about objects that will then be presented at the front end. -It responds to Kubernetes requests from the frontend. +## Introduction -## Configuration +See our announcement blog post [New Backstage feature: Kubernetes for Service Owners](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners) to learn more about the motivation behind developing the plugin. -### serviceLocatorMethod +## Setup & Configuration -This configures how to determine which clusters a component is running in. +This plugin must be explicitly added to a Backstage app, along with it's peer frontend plugin. -Currently, the only valid serviceLocatorMethod is: +The plugin requires configuration in the Backstage `app-config.yaml` to connect to a Kubernetes API control plane. -#### multiTenant +In addition, configuration of an entity's `catalog-info.yaml` helps identify which specific Kubernetes object(s) should be presented on a specific entity catalog page. -This configuration assumes that all components run on all the provided clusters. - -### clusterLocatorMethods - -This is used to determine where to retrieve cluster configuration from. - -Currently, the only valid serviceLocatorMethod is: - -#### config - -This clusterLocatorMethod will read cluster information in from config - -Example: - -```yaml -kubernetes: - serviceLocatorMethod: 'multiTenant' - clusterLocatorMethods: - - 'config' - clusters: - - url: http://127.0.0.1:9999 - name: minikube - serviceAccountToken: - authProvider: 'serviceAccount' - - url: http://127.0.0.2:9999 - name: gke-cluster-1 - authProvider: 'google' -``` - -##### clusters - -Used by the `config` `clusterLocatorMethods` to construct Kubernetes clients. - -###### url - -The base url to the Kubernetes control plane. Can be found by using the `Kubernetes master` result from running the `kubectl cluster-info` command. - -###### name - -A name to represent this cluster, this must be unique within the `clusters` array. Users will see this value in the Service Catalog Kubernetes plugin. - -###### authProvider - -This determines how the Kubernetes client authenticate with the Kubernetes cluster. Valid values are: - -| Value | Description | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | -| `google` | This will use a user's google auth token from the [google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | - -###### serviceAccount (optional) - -The service account token to be used when using the `authProvider`, `serviceAccount`. - -## RBAC - -The current RBAC permissions required are read-only cluster wide, for the following objects: - -- pods -- services -- configmaps -- deployments -- replicasets -- horizontalpodautoscalers -- ingresses +For more information, see the [formal documentation about the Kubernetes feature in Backstage](https://backstage.io/docs/features/kubernetes/overview). diff --git a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml index 50956bd7d3..68ef489170 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml +++ b/plugins/kubernetes-backend/examples/dice-roller/dice-roller-manifests.yaml @@ -18,6 +18,17 @@ spec: containers: - name: nginx image: nginx:1.14.2 + args: + - bash + - -c + - yes > /dev/null & yes > /dev/null & yes > /dev/null + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '50m' ports: - containerPort: 80 @@ -44,14 +55,134 @@ spec: image: nginx:1.14.2 ports: - containerPort: 80 + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' - name: side-car image: nginx:1.14.2 ports: - containerPort: 81 + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' - name: other-side-car image: nginx:1.14.2 ports: - containerPort: 82 + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller-bad-config + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller-bad-config + replicas: 2 + template: + metadata: + labels: + app: dice-roller-bad-config + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:6000000 + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' + ports: + - containerPort: 80 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller-big + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller-big + replicas: 1 + template: + metadata: + labels: + app: dice-roller-big + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:1.14.2 + resources: + requests: + memory: '64Mi' + cpu: '100000m' + limits: + memory: '128Mi' + cpu: '100000m' + ports: + - containerPort: 80 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dice-roller-bad-cm + labels: + 'backstage.io/kubernetes-id': dice-roller +spec: + selector: + matchLabels: + app: dice-roller-bad-cm + replicas: 1 + template: + metadata: + labels: + app: dice-roller-bad-cm + 'backstage.io/kubernetes-id': dice-roller + spec: + containers: + - name: nginx + image: nginx:1.14.2 + env: + - name: SOME_ENV_VAR + valueFrom: + configMapKeyRef: + name: some-cm + key: some-key + resources: + requests: + memory: '64Mi' + cpu: '50m' + limits: + memory: '128Mi' + cpu: '500m' + ports: + - containerPort: 80 --- apiVersion: autoscaling/v1 diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 66a36f8395..3823ee1899 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.2.3", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@aws-sdk/credential-provider-node": "^3.3.0", + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@kubernetes/client-node": "^0.12.1", + "@kubernetes/client-node": "^0.13.2", "@types/express": "^4.17.6", + "aws4": "^1.11.0", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", @@ -49,7 +51,8 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.2", + "@backstage/cli": "^0.6.0", + "@types/aws4": "^1.5.1", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/kubernetes-backend/schema.d.ts b/plugins/kubernetes-backend/schema.d.ts index 862cb6d2ce..31b6a2022b 100644 --- a/plugins/kubernetes-backend/schema.d.ts +++ b/plugins/kubernetes-backend/schema.d.ts @@ -20,8 +20,8 @@ export interface Config { clusters: { url: string; name: string; - serviceAccountToken: string; - authProvider: 'serviceAccount'; + serviceAccountToken: string | undefined; + authProvider: 'aws' | 'google' | 'serviceAccount'; }[]; }; } diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.ts new file mode 100644 index 0000000000..4411658ee6 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.test.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. + */ +const mockCredentialProvider = jest.fn(); +jest.mock('@aws-sdk/credential-provider-node', () => { + return { + defaultProvider: () => mockCredentialProvider, + }; +}); + +import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; + +describe('AwsIamKubernetesAuthTranslator tests', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('returns a signed url for aws credentials', async () => { + const authTranslator = new AwsIamKubernetesAuthTranslator(); + + mockCredentialProvider.mockImplementation(async () => { + // These credentials are not real. + // Pulled from example in docs: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html + return { + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretKeyId: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }; + }); + + const clusterDetails = await authTranslator.decorateClusterDetailsWithAuth({ + name: 'test-cluster', + url: '', + authProvider: 'aws', + }); + expect(clusterDetails.serviceAccountToken).toBeDefined(); + }); + + it('throws when unable to get aws credentials', async () => { + const authTranslator = new AwsIamKubernetesAuthTranslator(); + + mockCredentialProvider.mockImplementation(async () => { + throw new Error('not implemented'); + }); + + const promise = authTranslator.decorateClusterDetailsWithAuth({ + name: 'test-cluster', + url: '', + authProvider: 'aws', + }); + await expect(promise).rejects.toThrow('not implemented'); + }); +}); diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts new file mode 100644 index 0000000000..3b5ea59f18 --- /dev/null +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/AwsIamKubernetesAuthTranslator.ts @@ -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 { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { sign } from 'aws4'; +import { KubernetesAuthTranslator } from './types'; +import { ClusterDetails } from '..'; + +const base64 = (str: string) => + Buffer.from(str.toString(), 'binary').toString('base64'); +const prepend = (prep: string) => (str: string) => prep + str; +const replace = (search: string | RegExp, substitution: string) => ( + str: string, +) => str.replace(search, substitution); +const pipe = (fns: ReadonlyArray) => (thing: string): string => + fns.reduce((val, fn) => fn(val), thing); +const removePadding = replace(/=+$/, ''); +const makeUrlSafe = pipe([replace('+', '-'), replace('/', '_')]); + +export class AwsIamKubernetesAuthTranslator + implements KubernetesAuthTranslator { + async getBearerToken(clusterName: string): Promise { + const credentialProvider = defaultProvider(); + const credentials = await credentialProvider(); + const request = { + host: `sts.amazonaws.com`, + path: `/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Expires=60`, + headers: { + 'x-k8s-aws-id': clusterName, + }, + signQuery: true, + }; + const signedRequest = sign(request, { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }); + + return pipe([ + (signed: any) => `https://${signed.host}${signed.path}`, + base64, + removePadding, + makeUrlSafe, + prepend('k8s-aws-v1.'), + ])(signedRequest); + } + + async decorateClusterDetailsWithAuth( + clusterDetails: ClusterDetails, + ): Promise { + const clusterDetailsWithAuthToken: ClusterDetails = Object.assign( + {}, + clusterDetails, + ); + + clusterDetailsWithAuthToken.serviceAccountToken = await this.getBearerToken( + clusterDetails.name, + ); + 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 index 3b27eb012a..ec2f6ef120 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.test.ts @@ -18,6 +18,7 @@ import { KubernetesAuthTranslator } from './types'; import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator'; import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; +import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; describe('getKubernetesAuthTranslatorInstance', () => { const sut = KubernetesAuthTranslatorGenerator; @@ -29,6 +30,13 @@ describe('getKubernetesAuthTranslatorInstance', () => { expect(authTranslator instanceof GoogleKubernetesAuthTranslator).toBe(true); }); + it('can return an auth translator for aws auth', () => { + const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( + 'aws', + ); + expect(authTranslator instanceof AwsIamKubernetesAuthTranslator).toBe(true); + }); + it('can return an auth translator for serviceAccount auth', () => { const authTranslator: KubernetesAuthTranslator = sut.getKubernetesAuthTranslatorInstance( 'serviceAccount', diff --git a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts index f7cfc92112..4ae2aff35a 100644 --- a/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts +++ b/plugins/kubernetes-backend/src/kubernetes-auth-translator/KubernetesAuthTranslatorGenerator.ts @@ -17,6 +17,7 @@ import { KubernetesAuthTranslator } from './types'; import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator'; import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator'; +import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator'; export class KubernetesAuthTranslatorGenerator { static getKubernetesAuthTranslatorInstance( @@ -26,6 +27,9 @@ export class KubernetesAuthTranslatorGenerator { case 'google': { return new GoogleKubernetesAuthTranslator(); } + case 'aws': { + return new AwsIamKubernetesAuthTranslator(); + } case 'serviceAccount': { return new ServiceAccountKubernetesAuthTranslator(); } diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index 29233cf7fd..8d29439539 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -197,6 +197,27 @@ describe('KubernetesClientProvider', () => { }); // they're in testErrorResponse // eslint-disable-next-line jest/expect-expect + it('should return pods, bad request error', async () => { + await testErrorResponse( + { + response: { + statusCode: 400, + request: { + uri: { + pathname: '/some/path', + }, + }, + }, + }, + { + errorType: 'BAD_REQUEST', + resourcePath: '/some/path', + statusCode: 400, + }, + ); + }); + // they're in testErrorResponse + // eslint-disable-next-line jest/expect-expect it('should return pods, unauthorized error', async () => { await testErrorResponse( { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index d9c6ee6d1a..991174e78a 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -74,6 +74,8 @@ function fetchResultsToResponseWrapper( const statusCodeToErrorType = (statusCode: number): KubernetesErrorTypes => { switch (statusCode) { + case 400: + return 'BAD_REQUEST'; case 401: return 'UNAUTHORIZED_ERROR'; case 500: diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts index 2dd1e32b96..811a3c8acb 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts @@ -85,18 +85,18 @@ export const handleGetKubernetesObjectsForService: GetKubernetesObjectsForServic ] || `backstage.io/kubernetes-id=${requestBody.entity.metadata.name}`; return Promise.all( - clusterDetailsDecoratedForAuth.map(clusterDetails => { + clusterDetailsDecoratedForAuth.map(clusterDetailsItem => { return fetcher .fetchObjectsForService({ serviceId, - clusterDetails, + clusterDetails: clusterDetailsItem, objectTypesToFetch, labelSelector, } as ObjectFetchParams) .then(result => { return { cluster: { - name: clusterDetails.name, + name: clusterDetailsItem.name, }, resources: result.responses, errors: result.errors, diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 9f0b90c032..86191c6d72 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -135,6 +135,7 @@ export interface KubernetesClustersSupplier { } export type KubernetesErrorTypes = + | 'BAD_REQUEST' | 'UNAUTHORIZED_ERROR' | 'SYSTEM_ERROR' | 'UNKNOWN_ERROR'; @@ -147,4 +148,4 @@ export interface KubernetesFetchError { export type ServiceLocatorMethod = 'multiTenant' | 'http'; // TODO implement http export type ClusterLocatorMethod = 'config'; -export type AuthProviderType = 'google' | 'serviceAccount'; +export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index ef5b51cfb9..311bd5edaa 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,70 @@ # @backstage/plugin-kubernetes +## 0.3.8 + +### Patch Changes + +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.3.7 + +### Patch Changes + +- 681111228: Add AWS auth provider for Kubernetes +- a6e3b9596: Improve error reporting for plugin misconfiguration. +- Updated dependencies [681111228] + - @backstage/plugin-kubernetes-backend@0.2.6 + +## 0.3.6 + +### Patch Changes + +- d014185db: Show Kubernetes Service manifests. + Show Kubernetes Ingress manifests. +- Updated dependencies [def2307f3] +- Updated dependencies [d54857099] +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/plugin-kubernetes-backend@0.2.5 + - @backstage/core@0.5.0 + +## 0.3.5 + +### Patch Changes + +- 1fea88fd0: Fixed an issue where assets weren't properly bundled in the published package. + +## 0.3.4 + +### Patch Changes + +- 5a9a7e7c2: Revamped Kubernetes UI and added error reporting/detection +- 3e7c09c84: Minor updates to display of errors +- Updated dependencies [5a9a7e7c2] +- Updated dependencies [f3b064e1c] +- Updated dependencies [265a7ab30] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/plugin-kubernetes-backend@0.2.4 + - @backstage/catalog-model@0.6.1 + - @backstage/core@0.4.4 + ## 0.3.3 ### Patch Changes diff --git a/plugins/kubernetes/README.md b/plugins/kubernetes/README.md index a343764b70..15dfd16b17 100644 --- a/plugins/kubernetes/README.md +++ b/plugins/kubernetes/README.md @@ -1,9 +1,29 @@ -# kubernetes +# Kubernetes -Welcome to the kubernetes plugin! +Welcome to the Backstage Kubernetes frontend plugin! + +This plugin exposes information about your entity-specific Kubernetes objects with a desire to provide value to the service owner, rather than just a Kubernetes cluster administrator. + +It will elevate the visibility of errors where identified, and provide drill down about the deployments, pods, and other objects for a service. + +It directly interfaces with the [Kubernetes Backend Plugin (`@backstage-plugin-kubernetes-backend`)](https://github.com/backstage/backstage/tree/master/plugins/kubernetes-backend). _This plugin was created through the Backstage CLI_ +## Introduction + +See our announcement blog post [New Backstage feature: Kubernetes for Service Owners](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners) to learn more about the motivation behind developing the plugin. + +## Setup & Configuration + +This plugin must be explicitly added to a Backstage app, along with it's peer backend plugin. + +It requires configuration in the Backstage `app-config.yaml` to connect to a Kubernetes API control plane. + +In addition, configuration of an entity's `catalog-info.yaml` helps identify which specific Kubernetes object(s) should be presented on a specific entity catalog page. + +For more information, see the [formal documentation about the Kubernetes feature in Backstage](https://backstage.io/docs/features/kubernetes/overview). + ## 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). @@ -11,40 +31,3 @@ Your plugin has been added to the example app in this repository, meaning you'll 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. - -## Surfacing your Kubernetes components as part of an entity - -There are two ways to surface your kubernetes components as part of an entity. -The label selector takes precedence over the annotation/service id. - -### Common `backstage.io/kubernetes-id` label - -#### Adding the entity annotation - -In order for Backstage to detect that an entity has Kubernetes components, -the following annotation should be added to the entity. - -```yaml -annotations: - 'backstage.io/kubernetes-id': dice-roller -``` - -#### Labeling Kubernetes components - -In order for Kubernetes components to show up in the service catalog -as a part of an entity, Kubernetes components must be labeled with the following label: - -```yaml -'backstage.io/kubernetes-id': -``` - -### label selector query annotation - -#### Adding a label selector query annotation - -You can write your own custom label selector query that backstage will use to lookup the objects (similar to `kubectl --selector="your query here"`) -review the documentation [here](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) for more info - -```yaml -'backstage.io/kubernetes-label-selector': 'app=my-app,component=front-end' -``` diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index f352fb9c34..de93d21348 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -14,6 +14,6 @@ * limitations under the License. */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src'; +import { kubernetesPlugin } from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(kubernetesPlugin).render(); diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 4404b5e845..9013fa9395 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.3.3", + "version": "0.3.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,23 +31,25 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", + "@backstage/catalog-model": "^0.7.1", + "@backstage/plugin-catalog-react": "^0.0.2", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.3", - "@backstage/plugin-kubernetes-backend": "^0.2.3", - "@backstage/theme": "^0.2.2", - "@kubernetes/client-node": "^0.12.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-kubernetes-backend": "^0.2.6", + "@backstage/theme": "^0.2.3", + "@kubernetes/client-node": "^0.13.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "js-yaml": "^4.0.0", "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.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/kubernetes/schema.d.ts b/plugins/kubernetes/schema.d.ts index f3f3c98f8c..39598edb39 100644 --- a/plugins/kubernetes/schema.d.ts +++ b/plugins/kubernetes/schema.d.ts @@ -35,11 +35,11 @@ export interface Config { /** * @visibility secret */ - serviceAccountToken: string; + serviceAccountToken: string | undefined; /** * @visibility frontend */ - authProvider: 'serviceAccount'; + authProvider: 'aws' | 'google' | 'serviceAccount'; }[]; }; } diff --git a/plugins/kubernetes/src/Router.tsx b/plugins/kubernetes/src/Router.tsx index 20a4c04285..36851b3c46 100644 --- a/plugins/kubernetes/src/Router.tsx +++ b/plugins/kubernetes/src/Router.tsx @@ -16,15 +16,22 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Route, Routes } from 'react-router-dom'; - import { rootCatalogKubernetesRouteRef } from './plugin'; import { KubernetesContent } from './components/KubernetesContent'; import { MissingAnnotationEmptyState } from '@backstage/core'; const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; -export const Router = ({ entity }: { entity: Entity }) => { +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; +}; + +export const Router = (_props: Props) => { + const { entity } = useEntity(); + const kubernetesAnnotationValue = entity.metadata.annotations?.[KUBERNETES_ANNOTATION]; diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index b81a799d6b..287b411259 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -43,7 +43,15 @@ export class KubernetesBackendClient implements KubernetesApi { if (!response.ok) { const payload = await response.text(); - const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + let message; + switch (response.status) { + case 404: + message = + 'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.'; + break; + default: + message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + } throw new Error(message); } diff --git a/plugins/kubernetes/src/assets/emptystate.svg b/plugins/kubernetes/src/assets/emptystate.svg new file mode 100644 index 0000000000..fa7f19123e --- /dev/null +++ b/plugins/kubernetes/src/assets/emptystate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx b/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx deleted file mode 100644 index 5a013eb2c6..0000000000 --- a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { 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 deleted file mode 100644 index 77ea844611..0000000000 --- a/plugins/kubernetes/src/components/ConfigMaps/__fixtures__/configmap.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "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/DeploymentTables/DeploymentTables.tsx b/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx deleted file mode 100644 index a009c20d7e..0000000000 --- a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.tsx +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { 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/DeploymentsAccordions/DeploymentDrawer.test.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.test.tsx new file mode 100644 index 0000000000..5bf2c16de2 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.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 { render } from '@testing-library/react'; +import * as deployments from './__fixtures__/2-deployments.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { DeploymentDrawer } from './DeploymentDrawer'; + +describe('DeploymentDrawer', () => { + it('should render deployment drawer', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getAllByText('dice-roller')).toHaveLength(2); + expect(getAllByText('Deployment')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Strategy')).toBeInTheDocument(); + expect(getByText('Rolling Update:')).toBeInTheDocument(); + expect(getByText('Max Surge: 25%')).toBeInTheDocument(); + expect(getByText('Max Unavailable: 25%')).toBeInTheDocument(); + expect(getByText('Type: RollingUpdate')).toBeInTheDocument(); + expect(getByText('Min Ready Seconds')).toBeInTheDocument(); + expect(getByText('???')).toBeInTheDocument(); + expect(getByText('Progress Deadline Seconds')).toBeInTheDocument(); + expect(getByText('600')).toBeInTheDocument(); + expect(getByText('Progressing')).toBeInTheDocument(); + expect(getByText('Available')).toBeInTheDocument(); + expect(getAllByText('True')).toHaveLength(2); + }); +}); diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx new file mode 100644 index 0000000000..8c1365769f --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.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 { V1Deployment } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { renderCondition } from '../../utils/pod'; +import { Typography, Grid } from '@material-ui/core'; + +export const DeploymentDrawer = ({ + deployment, + expanded, +}: { + deployment: V1Deployment; + expanded?: boolean; +}) => { + return ( + { + const conditions = (deploymentObj.status?.conditions ?? []) + .map(renderCondition) + .reduce((accum, next) => { + accum[next[0]] = next[1]; + return accum; + }, {} as { [key: string]: React.ReactNode }); + + return { + strategy: deploymentObj.spec?.strategy ?? '???', + minReadySeconds: deploymentObj.spec?.minReadySeconds ?? '???', + progressDeadlineSeconds: + deploymentObj.spec?.progressDeadlineSeconds ?? '???', + ...conditions, + }; + }} + > + + + + {deployment.metadata?.name ?? 'unknown object'} + + + + + Deployment + + + + + ); +}; diff --git a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.test.tsx similarity index 57% rename from plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx rename to plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.test.tsx index 3dacac7055..a522af6d4f 100644 --- a/plugins/kubernetes/src/components/DeploymentTables/DeploymentTables.test.tsx +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.test.tsx @@ -16,26 +16,32 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { DeploymentTables } from './DeploymentTables'; +import { DeploymentsAccordions } from './DeploymentsAccordions'; import * as twoDeployFixture from './__fixtures__/2-deployments.json'; import { wrapInTestApp } from '@backstage/test-utils'; -describe('DeploymentTables', () => { +describe('DeploymentsAccordions', () => { it('should render 2 deployments', async () => { const { getByText } = render( wrapInTestApp( - , + , ), ); - // title expect(getByText('dice-roller')).toBeInTheDocument(); - expect(getByText('dice-roller-canary')).toBeInTheDocument(); + expect(getByText('10 pods')).toBeInTheDocument(); + expect(getByText('No pods with errors')).toBeInTheDocument(); + expect(getByText('min replicas 10 / max replicas 15')).toBeInTheDocument(); + expect(getByText('current CPU usage: 30%')).toBeInTheDocument(); + expect(getByText('target CPU usage: 50%')).toBeInTheDocument(); - // pod names - expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); - expect( - getByText('dice-roller-canary-7d64cd756c-55rfq'), - ).toBeInTheDocument(); + expect(getByText('dice-roller-canary')).toBeInTheDocument(); + expect(getByText('2 pods')).toBeInTheDocument(); + expect(getByText('1 pod with errors')).toBeInTheDocument(); }); }); diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx new file mode 100644 index 0000000000..e03a749185 --- /dev/null +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentsAccordions.tsx @@ -0,0 +1,221 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DeploymentResources } from '../../types/types'; +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, + Typography, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { V1OwnerReference } from '@kubernetes/client-node/dist/gen/model/v1OwnerReference'; +import { + V1Deployment, + V1Pod, + V1ReplicaSet, + V1HorizontalPodAutoscaler, +} from '@kubernetes/client-node'; +import { StatusError, StatusOK } from '@backstage/core'; +import { PodsTable } from '../Pods'; +import { DeploymentDrawer } from './DeploymentDrawer'; +import { HorizontalPodAutoscalerDrawer } from '../HorizontalPodAutoscalers'; + +type DeploymentsAccordionsProps = { + deploymentResources: DeploymentResources; + clusterPodNamesWithErrors: Set; + children?: React.ReactNode; +}; + +type DeploymentAccordionProps = { + deployment: V1Deployment; + ownedPods: V1Pod[]; + matchingHpa?: V1HorizontalPodAutoscaler; + clusterPodNamesWithErrors: Set; + children?: React.ReactNode; +}; + +type DeploymentSummaryProps = { + deployment: V1Deployment; + numberOfCurrentPods: number; + numberOfPodsWithErrors: number; + hpa?: V1HorizontalPodAutoscaler; + children?: React.ReactNode; +}; + +const DeploymentSummary = ({ + deployment, + numberOfCurrentPods, + numberOfPodsWithErrors, + hpa, +}: DeploymentSummaryProps) => { + return ( + + + + + + + + {hpa && ( + + + + + + min replicas {hpa.spec?.minReplicas ?? '?'} / max replicas{' '} + {hpa.spec?.maxReplicas ?? '?'} + + + + + current CPU usage:{' '} + {hpa.status?.currentCPUUtilizationPercentage ?? '?'}% + + + + + target CPU usage:{' '} + {hpa.spec?.targetCPUUtilizationPercentage ?? '?'}% + + + + + + )} + + + {numberOfCurrentPods} pods + + + {numberOfPodsWithErrors > 0 ? ( + + {numberOfPodsWithErrors} pod + {numberOfPodsWithErrors > 1 ? 's' : ''} with errors + + ) : ( + No pods with errors + )} + + + + ); +}; + +const DeploymentAccordion = ({ + deployment, + ownedPods, + matchingHpa, + clusterPodNamesWithErrors, +}: DeploymentAccordionProps) => { + const podsWithErrors = ownedPods.filter(p => + clusterPodNamesWithErrors.has(p.metadata?.name ?? ''), + ); + + return ( + + }> + + + + + + + ); +}; + +export const DeploymentsAccordions = ({ + deploymentResources, + clusterPodNamesWithErrors, +}: DeploymentsAccordionsProps) => { + const isOwnedBy = ( + ownerReferences: V1OwnerReference[], + obj: V1Pod | V1ReplicaSet | V1Deployment, + ): boolean => { + return ownerReferences?.some(or => or.name === obj.metadata?.name); + }; + + return ( + + {deploymentResources.deployments.map((deployment, i) => ( + + {deploymentResources.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 = deploymentResources.pods.filter(pod => + isOwnedBy(pod.metadata?.ownerReferences ?? [], rs), + ); + + const matchingHpa = deploymentResources.horizontalPodAutoscalers.find( + (hpa: V1HorizontalPodAutoscaler) => { + return ( + (hpa.spec?.scaleTargetRef?.kind ?? '').toLowerCase() === + 'deployment' && + (hpa.spec?.scaleTargetRef?.name ?? '') === + (deployment.metadata?.name ?? 'unknown-deployment') + ); + }, + ); + + return ( + + + + ); + })} + + ))} + + ); +}; diff --git a/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json b/plugins/kubernetes/src/components/DeploymentsAccordions/__fixtures__/2-deployments.json similarity index 97% rename from plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json rename to plugins/kubernetes/src/components/DeploymentsAccordions/__fixtures__/2-deployments.json index e51d0d0b5f..f5efdbf1cb 100644 --- a/plugins/kubernetes/src/components/DeploymentTables/__fixtures__/2-deployments.json +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/__fixtures__/2-deployments.json @@ -4431,5 +4431,89 @@ "updatedReplicas": 2 } } + ], + "horizontalPodAutoscalers": [ + { + "apiVersion": "autoscaling/v1", + "kind": "HorizontalPodAutoscaler", + "metadata": { + "annotations": { + "autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2021-01-05T10:26:04Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2021-01-05T10:26:04Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: no metrics returned from resource metrics API\"}]", + "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": "2021-01-05T10:25:48Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller" + }, + "managedFields": [ + { + "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-client-side-apply", + "operation": "Update", + "time": "2021-01-05T10:25:48Z" + }, + { + "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": "2021-01-05T10:26:04Z" + } + ], + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "598", + "selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller", + "uid": "dd7c5329-567c-43c2-b159-756808d90a8e" + }, + "spec": { + "maxReplicas": 15, + "minReplicas": 10, + "scaleTargetRef": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "dice-roller" + }, + "targetCPUUtilizationPercentage": 50 + }, + "status": { + "currentReplicas": 10, + "desiredReplicas": 0, + "currentCPUUtilizationPercentage": 30 + } + } ] } diff --git a/plugins/cost-insights/src/components/AlertActionCardList/index.ts b/plugins/kubernetes/src/components/DeploymentsAccordions/index.ts similarity index 90% rename from plugins/cost-insights/src/components/AlertActionCardList/index.ts rename to plugins/kubernetes/src/components/DeploymentsAccordions/index.ts index a0ce118482..923fec624b 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/index.ts +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/index.ts @@ -13,5 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -export { AlertActionCardList } from './AlertActionCardList'; +export { DeploymentsAccordions } from './DeploymentsAccordions'; diff --git a/plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx b/plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx new file mode 100644 index 0000000000..e33ded1fac --- /dev/null +++ b/plugins/kubernetes/src/components/ErrorReporting/ErrorReporting.tsx @@ -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 * as React from 'react'; +import { Table, TableColumn, InfoCard } from '@backstage/core'; +import { DetectedError, DetectedErrorsByCluster } from '../../error-detection'; +import { Chip, Typography, Grid } from '@material-ui/core'; +import EmptyStateImage from '../../assets/emptystate.svg'; + +type ErrorReportingProps = { + detectedErrors: DetectedErrorsByCluster; +}; + +const columns: TableColumn[] = [ + { + title: 'cluster', + width: '15%', + render: (detectedError: DetectedError) => detectedError.cluster, + }, + { + title: 'kind', + width: '15%', + render: (detectedError: DetectedError) => detectedError.kind, + }, + { + title: 'name', + width: '30%', + render: (detectedError: DetectedError) => { + const errorCount = detectedError.names.length; + + if (errorCount === 0) { + // This shouldn't happen + return null; + } + + const displayName = detectedError.names[0]; + + const otherErrorCount = errorCount - 1; + + return ( + <> + {displayName}{' '} + {otherErrorCount > 0 && ( + 1 ? 's' : '' + }`} + size="small" + /> + )} + + ); + }, + }, + { + title: 'messages', + width: '40%', + render: (detectedError: DetectedError) => ( + <> + {detectedError.message.map((m, i) => ( +
{m}
+ ))} + + ), + }, +]; + +const sortBySeverity = (a: DetectedError, b: DetectedError) => { + if (a.severity < b.severity) { + return 1; + } else if (b.severity < a.severity) { + return -1; + } + return 0; +}; + +export const ErrorEmptyState = () => { + return ( + + + + Nice! There are no errors to report! + + + + EmptyState + + + ); +}; + +export const ErrorReporting = ({ detectedErrors }: ErrorReportingProps) => { + const errors = Array.from(detectedErrors.values()) + .flat() + .sort(sortBySeverity); + + return ( + <> + {errors.length === 0 ? ( + + + + ) : ( +
+ )} + + ); +}; diff --git a/plugins/kubernetes/src/components/ErrorReporting/index.ts b/plugins/kubernetes/src/components/ErrorReporting/index.ts new file mode 100644 index 0000000000..87ce11ffd8 --- /dev/null +++ b/plugins/kubernetes/src/components/ErrorReporting/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 { ErrorReporting } from './ErrorReporting'; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.test.tsx similarity index 59% rename from plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx rename to plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.test.tsx index eb5d407c76..fc64de0f0a 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.test.tsx +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.test.tsx @@ -16,37 +16,35 @@ import React from 'react'; import { render } from '@testing-library/react'; -import * as hpaFixture from './__fixtures__/horizontalpodautoscalers.json'; +import * as hpas from './__fixtures__/horizontalpodautoscalers.json'; import { wrapInTestApp } from '@backstage/test-utils'; -import { HorizontalPodAutoscalers } from './HorizontalPodAutoscalers'; +import { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer'; -describe('HorizontalPodAutoscalers', () => { - it('should render horizontalpodautoscaler', async () => { - const { getByText, getAllByText } = render( +describe('HorizontalPodAutoscalersDrawer', () => { + it('should render hpa drawer', async () => { + const { getByText } = render( wrapInTestApp( - , + +

CHILD

+
, ), ); - // 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('CHILD')).toBeInTheDocument(); + expect(getByText('HorizontalPodAutoscaler')).toBeInTheDocument(); + expect(getByText('YAML')).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); + expect(getByText('30')).toBeInTheDocument(); + expect(getByText('Min Replicas')).toBeInTheDocument(); + expect(getByText('10')).toBeInTheDocument(); + expect(getByText('Max Replicas')).toBeInTheDocument(); + expect(getByText('15')).toBeInTheDocument(); + expect(getByText('Current Replicas')).toBeInTheDocument(); + expect(getByText('13')).toBeInTheDocument(); + expect(getByText('Desired Replicas')).toBeInTheDocument(); + expect(getByText('14')).toBeInTheDocument(); }); }); diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx new file mode 100644 index 0000000000..26dcdfd249 --- /dev/null +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.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 { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; + +export const HorizontalPodAutoscalerDrawer = ({ + hpa, + expanded, + children, +}: { + hpa: V1HorizontalPodAutoscaler; + expanded?: boolean; + children?: React.ReactNode; +}) => { + return ( + { + return { + targetCPUUtilizationPercentage: + hpaObject.spec?.targetCPUUtilizationPercentage, + currentCPUUtilizationPercentage: + hpaObject.status?.currentCPUUtilizationPercentage, + minReplicas: hpaObject.spec?.minReplicas, + maxReplicas: hpaObject.spec?.maxReplicas, + currentReplicas: hpaObject.status?.currentReplicas, + desiredReplicas: hpaObject.status?.desiredReplicas, + }; + }} + > + {children} + + ); +}; diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx deleted file mode 100644 index b4540840d2..0000000000 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalers.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { 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 index 6bc6028246..6afdda48ed 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/__fixtures__/horizontalpodautoscalers.json @@ -74,8 +74,9 @@ "targetCPUUtilizationPercentage": 50 }, "status": { - "currentReplicas": 10, - "desiredReplicas": 0 + "currentReplicas": 13, + "desiredReplicas": 14, + "currentCPUUtilizationPercentage": 30 } } ] diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts index 3eebbe9813..b5ca533079 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { HorizontalPodAutoscalers } from './HorizontalPodAutoscalers'; +export { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer'; diff --git a/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx b/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx deleted file mode 100644 index f2507a3f39..0000000000 --- a/plugins/kubernetes/src/components/Ingresses/Ingresses.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { 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 deleted file mode 100644 index 37932601a1..0000000000 --- a/plugins/kubernetes/src/components/Ingresses/Ingresses.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { 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 deleted file mode 100644 index fd0bc5ec43..0000000000 --- a/plugins/kubernetes/src/components/Ingresses/__fixtures__/ingress.json +++ /dev/null @@ -1,87 +0,0 @@ -[ - { - "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/IngressesAccordions/IngressDrawer.test.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.test.tsx new file mode 100644 index 0000000000..cf37eb3e79 --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.test.tsx @@ -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 React from 'react'; +import { render } from '@testing-library/react'; +import * as ingresses from './__fixtures__/2-ingresses.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { IngressDrawer } from './IngressDrawer'; + +describe('IngressDrawer', () => { + it('should render ingress drawer', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getAllByText('awesome-service')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Rules')).toBeInTheDocument(); + expect(getByText('Host: api.awesome-host.io')).toBeInTheDocument(); + expect(getAllByText('Service Port: 80')).toHaveLength(2); + expect(getAllByText('Service Name: awesome-service')).toHaveLength(2); + }); +}); diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx new file mode 100644 index 0000000000..d619700ac2 --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { Typography, Grid } from '@material-ui/core'; + +export const IngressDrawer = ({ + ingress, + expanded, +}: { + ingress: ExtensionsV1beta1Ingress; + expanded?: boolean; +}) => { + return ( + { + return ingressObject.spec || {}; + }} + > + + + + {ingress.metadata?.name ?? 'unknown object'} + + + + + Ingress + + + + + ); +}; diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.test.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.test.tsx new file mode 100644 index 0000000000..580561095e --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.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 { render } from '@testing-library/react'; +import * as oneIngressFixture from './__fixtures__/2-ingresses.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { IngressesAccordions } from './IngressesAccordions'; + +describe('IngressesAccordions', () => { + it('should render 1 ingress', async () => { + const { getByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getByText('awesome-service')).toBeInTheDocument(); + expect(getByText('Ingress')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.tsx new file mode 100644 index 0000000000..6ecfa63e23 --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressesAccordions.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 { GroupedResponses } from '../../types/types'; +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { StructuredMetadataTable } from '@backstage/core'; +import { IngressDrawer } from './IngressDrawer'; + +type IngressesAccordionsProps = { + deploymentResources: GroupedResponses; +}; + +type IngressAccordionProps = { + ingress: ExtensionsV1beta1Ingress; +}; + +type IngressSummaryProps = { + ingress: ExtensionsV1beta1Ingress; +}; + +const IngressSummary = ({ ingress }: IngressSummaryProps) => { + return ( + + + + + + + + + + ); +}; + +type IngressCardProps = { + ingress: ExtensionsV1beta1Ingress; +}; + +const IngressCard = ({ ingress }: IngressCardProps) => { + return ( + + ); +}; + +const IngressAccordion = ({ ingress }: IngressAccordionProps) => { + return ( + + }> + + + + + + + ); +}; +export const IngressesAccordions = ({ + deploymentResources, +}: IngressesAccordionsProps) => { + return ( + + {deploymentResources.ingresses.map((ingress, i) => ( + + + + ))} + + ); +}; diff --git a/plugins/kubernetes/src/components/IngressesAccordions/__fixtures__/2-ingresses.json b/plugins/kubernetes/src/components/IngressesAccordions/__fixtures__/2-ingresses.json new file mode 100644 index 0000000000..136c70697d --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/__fixtures__/2-ingresses.json @@ -0,0 +1,57 @@ +{ + "ingresses": [ + { + "metadata": { + "annotations": { + "artifact.spinnaker.io/location": "default", + "artifact.spinnaker.io/name": "awesome-service", + "artifact.spinnaker.io/type": "kubernetes/ingress", + "kubernetes.io/ingress.class": "traefik", + "kubernetes.io/ingress.global-static-ip-name": "traefik-tcp-lb", + "moniker.spinnaker.io/application": "awesome-service", + "moniker.spinnaker.io/cluster": "ingress awesome-service" + }, + "creationTimestamp": "2018-11-16T14:00:13.000Z", + "generation": 11, + "labels": { + "app": "awesome-service", + "app.kubernetes.io/managed-by": "spinnaker", + "app.kubernetes.io/name": "awesome-service" + }, + "name": "awesome-service", + "namespace": "default", + "resourceVersion": "564824116", + "selfLink": "/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/awesome-service", + "uid": "f072e0b4-e9a7-11e8-af65-42010a9c0022" + }, + "spec": { + "rules": [ + { + "host": "api.awesome-host.io", + "http": { + "paths": [ + { + "backend": { + "serviceName": "awesome-service", + "servicePort": 80 + }, + "path": "/v1/awesome-service" + }, + { + "backend": { + "serviceName": "awesome-service", + "servicePort": 80 + }, + "path": "/v1/awesome-services" + } + ] + } + } + ] + }, + "status": { + "loadBalancer": {} + } + } + ] +} diff --git a/plugins/kubernetes/src/components/IngressesAccordions/index.ts b/plugins/kubernetes/src/components/IngressesAccordions/index.ts new file mode 100644 index 0000000000..a71f53fac7 --- /dev/null +++ b/plugins/kubernetes/src/components/IngressesAccordions/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 { IngressesAccordions } from './IngressesAccordions'; diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx index 6fa6b3114c..8dff742502 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.test.tsx @@ -33,7 +33,7 @@ describe('ErrorPanel', () => { // title expect( getByText( - 'There was an error retrieving some Kubernetes resources for the entity: THIS_ENTITY', + 'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.', ), ).toBeInTheDocument(); @@ -67,7 +67,7 @@ describe('ErrorPanel', () => { // title expect( getByText( - 'There was an error retrieving some Kubernetes resources for the entity: THIS_ENTITY', + 'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.', ), ).toBeInTheDocument(); diff --git a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx index 1b8336dd72..650e6ddff1 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/ErrorPanel.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { WarningPanel } from '@backstage/core'; import { Typography } from '@material-ui/core'; -import { ClusterObjects } from '../../../../kubernetes-backend/src'; +import { ClusterObjects } from '@backstage/plugin-kubernetes-backend'; const clustersWithErrorsToErrorMessage = ( clustersWithErrors: ClusterObjects[], @@ -52,8 +52,8 @@ export const ErrorPanel = ({ clustersWithErrors, }: ErrorPanelProps) => ( {clustersWithErrors && (
Errors: {clustersWithErrorsToErrorMessage(clustersWithErrors)}
diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index 65c1102d47..18351c7455 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -14,87 +14,148 @@ * limitations under the License. */ -import React, { ReactElement, useEffect, useState } from 'react'; -import { Grid, TabProps } from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, + Typography, +} from '@material-ui/core'; import { Config } from '@backstage/config'; import { - CardTab, configApiRef, Content, Page, Progress, - TabbedCard, + StatusError, + StatusOK, useApi, } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; import { kubernetesApiRef } from '../../api/types'; import { - KubernetesRequestBody, ClusterObjects, - FetchResponse, + KubernetesRequestBody, ObjectsByEntityResponse, } 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'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { DeploymentsAccordions } from '../DeploymentsAccordions'; +import { ErrorReporting } from '../ErrorReporting'; +import { groupResponses } from '../../utils/response'; +import { DetectedError, detectErrors } from '../../error-detection'; +import { IngressesAccordions } from '../IngressesAccordions'; +import { ServicesAccordions } from '../ServicesAccordions'; -interface GroupedResponses extends DeploymentTriple { - services: V1Service[]; - configMaps: V1ConfigMap[]; - horizontalPodAutoscalers: V1HorizontalPodAutoscaler[]; - ingresses: ExtensionsV1beta1Ingress[]; -} +type ClusterSummaryProps = { + clusterName: string; + totalNumberOfPods: number; + numberOfPodsWithErrors: number; + children?: React.ReactNode; +}; -// 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, +const ClusterSummary = ({ + clusterName, + totalNumberOfPods, + numberOfPodsWithErrors, +}: ClusterSummaryProps) => { + return ( + + + + {clusterName} + + Cluster + + + + + + + + + {totalNumberOfPods} pods + + + {numberOfPodsWithErrors > 0 ? ( + {numberOfPodsWithErrors} pods with errors + ) : ( + No pods with errors + )} + + + + ); +}; + +type ClusterProps = { + clusterObjects: ClusterObjects; + detectedErrors?: DetectedError[]; + children?: React.ReactNode; +}; + +const Cluster = ({ clusterObjects, detectedErrors }: ClusterProps) => { + const groupedResponses = groupResponses(clusterObjects.resources); + + const podsWithErrors = new Set( + detectedErrors + ?.filter(de => de.kind === 'Pod') + .map(de => de.names) + .flat() ?? [], + ); + + return ( + <> + + }> + + + + + + + + + + + + + + + + + + + ); }; @@ -148,104 +209,64 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { const clustersWithErrors = kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? []; + const detectedErrors = + kubernetesObjects !== undefined + ? detectErrors(kubernetesObjects) + : new Map(); + return ( - - {kubernetesObjects === undefined && error === undefined && ( - - )} + {kubernetesObjects === undefined && error === undefined && } - {/* errors retrieved from the kubernetes clusters */} - {clustersWithErrors.length > 0 && ( - - )} - - {/* other errors */} - {error !== undefined && ( - - )} - - {kubernetesObjects?.items.map((item, i) => ( - - + {/* errors retrieved from the kubernetes clusters */} + {clustersWithErrors.length > 0 && ( + + + - ))} - + + )} + + {/* other errors */} + {error !== undefined && ( + + + + + + )} + + {kubernetesObjects && ( + + + + + + + + + Your Clusters + + + {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/KubernetesDrawer/KubernetesDrawer.tsx b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx new file mode 100644 index 0000000000..ee4de96177 --- /dev/null +++ b/plugins/kubernetes/src/components/KubernetesDrawer/KubernetesDrawer.tsx @@ -0,0 +1,204 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, { ChangeEvent, useState } from 'react'; +import { + Button, + Typography, + makeStyles, + IconButton, + createStyles, + Theme, + Drawer, + Switch, + FormControlLabel, + Grid, +} from '@material-ui/core'; +import Close from '@material-ui/icons/Close'; +import { V1ObjectMeta } from '@kubernetes/client-node'; +import { withStyles } from '@material-ui/core/styles'; +import { CodeSnippet, StructuredMetadataTable } from '@backstage/core'; +import jsYaml from 'js-yaml'; + +const useDrawerStyles = makeStyles((theme: Theme) => + createStyles({ + paper: { + width: '50%', + justifyContent: 'space-between', + padding: theme.spacing(2.5), + }, + }), +); + +const useDrawerContentStyles = makeStyles((_: Theme) => + createStyles({ + header: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + options: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + icon: { + fontSize: 20, + }, + content: { + height: '80%', + }, + }), +); + +const PodDrawerButton = withStyles({ + root: { + padding: '6px 5px', + }, + label: { + textTransform: 'none', + }, +})(Button); + +interface KubernetesDrawerable { + metadata?: V1ObjectMeta; +} + +interface KubernetesDrawerContentProps { + toggleDrawer: (e: ChangeEvent<{}>, isOpen: boolean) => void; + object: T; + renderObject: (obj: T) => object; + kind: string; +} + +const KubernetesDrawerContent = ({ + toggleDrawer, + object, + renderObject, + kind, +}: KubernetesDrawerContentProps) => { + const [isYaml, setIsYaml] = useState(false); + + const classes = useDrawerContentStyles(); + + return ( + <> +
+ + + + {object.metadata?.name ?? 'unknown name'} + + + + + {kind} + + + + toggleDrawer(e, false)} + color="inherit" + > + + +
+
+ { + setIsYaml(event.target.checked); + }} + name="YAML" + /> + } + label="YAML" + /> +
+
+ {isYaml && } + {!isYaml && } +
+ + ); +}; +interface KubernetesDrawerProps { + object: T; + renderObject: (obj: T) => object; + buttonVariant?: 'h5' | 'subtitle2'; + kind: string; + expanded?: boolean; + children?: React.ReactNode; +} + +export const KubernetesDrawer = ({ + object, + renderObject, + kind, + buttonVariant = 'subtitle2', + expanded = false, + children, +}: KubernetesDrawerProps) => { + const [isOpen, setIsOpen] = useState(expanded); + const classes = useDrawerStyles(); + + const toggleDrawer = (e: ChangeEvent<{}>, newValue: boolean) => { + e.stopPropagation(); + setIsOpen(newValue); + }; + + return ( + <> + toggleDrawer(e, true)} + onFocus={event => event.stopPropagation()} + > + {children === undefined ? ( + + {object.metadata?.name ?? 'unknown object'} + + ) : ( + children + )} + + toggleDrawer(e, false)} + onClick={event => event.stopPropagation()} + > + + + + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx new file mode 100644 index 0000000000..f166dc5f47 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodDrawer.test.tsx @@ -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 React from 'react'; +import { render } from '@testing-library/react'; +import * as pod from './__fixtures__/pod.json'; +import * as crashingPod from './__fixtures__/crashing-pod.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { PodDrawer } from './PodDrawer'; + +describe('PodDrawer', () => { + it('should render pod', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp(), + ); + + expect(getAllByText('dice-roller-6c8646bfd-2m5hv')).toHaveLength(2); + expect(getByText('Pod')).toBeInTheDocument(); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Images')).toBeInTheDocument(); + expect(getByText('nginx=nginx:1.14.2')).toBeInTheDocument(); + expect(getByText('Phase')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getAllByText('Containers Ready')).toHaveLength(2); + expect(getByText('1/1')).toBeInTheDocument(); + expect(getByText('Total Restarts')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('Container Statuses')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + expect(getByText('Initialized')).toBeInTheDocument(); + expect(getByText('Ready')).toBeInTheDocument(); + expect(getByText('Pod Scheduled')).toBeInTheDocument(); + expect(getAllByText('True')).toHaveLength(4); + expect(getByText('Exposed Ports')).toBeInTheDocument(); + expect(getByText('Nginx:')).toBeInTheDocument(); + expect(getByText('Container Port: 80')).toBeInTheDocument(); + expect(getByText('Protocol: TCP')).toBeInTheDocument(); + }); + it('should render crashing pod', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp(), + ); + + expect(getAllByText('dice-roller-canary-7d64cd756c-55rfq')).toHaveLength(2); + expect(getByText('Pod')).toBeInTheDocument(); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Images')).toBeInTheDocument(); + expect(getByText('nginx=nginx:1.14.2')).toBeInTheDocument(); + expect(getByText('other-side-car=nginx:1.14.2')).toBeInTheDocument(); + expect(getByText('side-car=nginx:1.14.2')).toBeInTheDocument(); + expect(getByText('Phase')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getAllByText('Containers Ready')).toHaveLength(2); + expect(getByText('1/3')).toBeInTheDocument(); + expect(getByText('Total Restarts')).toBeInTheDocument(); + expect(getByText('76')).toBeInTheDocument(); + expect(getByText('Container Statuses')).toBeInTheDocument(); + expect(getByText('Container: side-car')).toBeInTheDocument(); + expect(getByText('Container: other-side-car')).toBeInTheDocument(); + expect(getAllByText('CrashLoopBackOff')).toHaveLength(2); + expect(getByText('Initialized')).toBeInTheDocument(); + expect(getByText('Ready')).toBeInTheDocument(); + expect(getByText('Pod Scheduled')).toBeInTheDocument(); + expect(getAllByText('True')).toHaveLength(2); + expect(getAllByText('False')).toHaveLength(2); + expect( + getAllByText('containers with unready status: [side-car other-side-car]'), + ).toHaveLength(2); + expect(getByText('Exposed Ports')).toBeInTheDocument(); + expect(getAllByText('Protocol: TCP')).toHaveLength(3); + expect(getByText('Nginx:')).toBeInTheDocument(); + expect(getByText('Container Port: 80')).toBeInTheDocument(); + expect(getByText('Side Car:')).toBeInTheDocument(); + expect(getByText('Container Port: 81')).toBeInTheDocument(); + expect(getByText('Other Side Car:')).toBeInTheDocument(); + expect(getByText('Container Port: 82')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/PodDrawer.tsx b/plugins/kubernetes/src/components/Pods/PodDrawer.tsx new file mode 100644 index 0000000000..fc9594a8a9 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodDrawer.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { V1Pod } from '@kubernetes/client-node'; +import { + containersReady, + containerStatuses, + totalRestarts, + imageChips, + renderCondition, +} from '../../utils/pod'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; + +export const PodDrawer = ({ + pod, + expanded, +}: { + pod: V1Pod; + expanded?: boolean; +}) => { + return ( + { + const phase = podObject.status?.phase ?? 'unknown'; + + const ports = + podObject.spec?.containers?.map(c => { + return { + [c.name]: c.ports, + }; + }) ?? 'N/A'; + + const conditions = (podObject.status?.conditions ?? []) + .map(renderCondition) + .reduce((accum, next) => { + accum[next[0]] = next[1]; + return accum; + }, {} as { [key: string]: React.ReactNode }); + + return { + images: imageChips(podObject), + phase: phase, + 'Containers Ready': containersReady(podObject), + 'Total Restarts': totalRestarts(podObject), + 'Container Statuses': containerStatuses(podObject), + ...conditions, + 'Exposed ports': ports, + }; + }} + /> + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.test.tsx new file mode 100644 index 0000000000..96f3035372 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodsTable.test.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 { render } from '@testing-library/react'; +import * as pod from './__fixtures__/pod.json'; +import * as crashingPod from './__fixtures__/crashing-pod.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { PodsTable } from './PodsTable'; + +describe('PodsTable', () => { + it('should render pod', async () => { + const { getByText } = render( + wrapInTestApp(), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + + // values + expect(getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('1/1')).toBeInTheDocument(); + expect(getByText('0')).toBeInTheDocument(); + expect(getByText('OK')).toBeInTheDocument(); + }); + it('should render crashing pod', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp(), + ); + + // titles + expect(getByText('name')).toBeInTheDocument(); + expect(getByText('phase')).toBeInTheDocument(); + expect(getByText('containers ready')).toBeInTheDocument(); + expect(getByText('total restarts')).toBeInTheDocument(); + expect(getByText('status')).toBeInTheDocument(); + + // values + expect( + getByText('dice-roller-canary-7d64cd756c-55rfq'), + ).toBeInTheDocument(); + expect(getByText('Running')).toBeInTheDocument(); + expect(getByText('1/3')).toBeInTheDocument(); + expect(getByText('76')).toBeInTheDocument(); + expect(getByText('Container: side-car')).toBeInTheDocument(); + expect(getByText('Container: other-side-car')).toBeInTheDocument(); + expect(getAllByText('CrashLoopBackOff')).toHaveLength(2); + }); +}); diff --git a/plugins/kubernetes/src/components/Pods/PodsTable.tsx b/plugins/kubernetes/src/components/Pods/PodsTable.tsx new file mode 100644 index 0000000000..fb2b26cd39 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/PodsTable.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 { Table, TableColumn } from '@backstage/core'; +import { V1Pod } from '@kubernetes/client-node'; +import { PodDrawer } from './PodDrawer'; +import { + containersReady, + containerStatuses, + totalRestarts, +} from '../../utils/pod'; + +const columns: TableColumn[] = [ + { + title: 'name', + highlight: true, + render: (pod: V1Pod) => , + }, + { + title: 'phase', + render: (pod: V1Pod) => pod.status?.phase ?? 'unknown', + }, + { + title: 'containers ready', + align: 'center', + render: containersReady, + }, + { + title: 'total restarts', + align: 'center', + render: totalRestarts, + type: 'numeric', + }, + { + title: 'status', + render: containerStatuses, + }, +]; + +type DeploymentTablesProps = { + pods: V1Pod[]; + children?: React.ReactNode; +}; + +export const PodsTable = ({ pods }: DeploymentTablesProps) => { + const tableStyle: React.CSSProperties = { + minWidth: '0', + width: '100%', + }; + + return ( +
+
+ + ); +}; diff --git a/plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json b/plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json new file mode 100644 index 0000000000..79acd2900d --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/__fixtures__/crashing-pod.json @@ -0,0 +1,233 @@ +{ + "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" + }, + "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" + } +} diff --git a/plugins/kubernetes/src/components/Pods/__fixtures__/pod.json b/plugins/kubernetes/src/components/Pods/__fixtures__/pod.json new file mode 100644 index 0000000000..5092efc089 --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/__fixtures__/pod.json @@ -0,0 +1,139 @@ +{ + "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" + }, + "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" + } +} diff --git a/plugins/kubernetes/src/components/Pods/index.ts b/plugins/kubernetes/src/components/Pods/index.ts new file mode 100644 index 0000000000..319950b1da --- /dev/null +++ b/plugins/kubernetes/src/components/Pods/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 { PodDrawer } from './PodDrawer'; +export { PodsTable } from './PodsTable'; diff --git a/plugins/kubernetes/src/components/Services/Services.test.tsx b/plugins/kubernetes/src/components/Services/Services.test.tsx deleted file mode 100644 index 5e74499bc5..0000000000 --- a/plugins/kubernetes/src/components/Services/Services.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { 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 deleted file mode 100644 index d0a4f71eae..0000000000 --- a/plugins/kubernetes/src/components/Services/Services.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { 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 deleted file mode 100644 index 7f9b49a9f6..0000000000 --- a/plugins/kubernetes/src/components/Services/__fixtures__/services.json +++ /dev/null @@ -1,164 +0,0 @@ -[ - { - "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/ServicesAccordions/ServiceDrawer.test.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.test.tsx new file mode 100644 index 0000000000..d9082ff632 --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.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 { render } from '@testing-library/react'; +import * as services from './__fixtures__/2-services.json'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ServiceDrawer } from './ServiceDrawer'; + +describe('ServiceDrawer', () => { + it('should render deployment drawer', async () => { + const { getByText, getAllByText } = render( + wrapInTestApp( + , + ), + ); + + expect(getAllByText('awesome-service-grpc')).toHaveLength(2); + expect(getAllByText('Service')).toHaveLength(2); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('Cluster IP')).toBeInTheDocument(); + expect(getByText('Ports')).toBeInTheDocument(); + expect(getByText('Target Port: 1997')).toBeInTheDocument(); + expect(getByText('App: awesome-service')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx new file mode 100644 index 0000000000..d0962c6314 --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { V1Service } from '@kubernetes/client-node'; +import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; +import { Typography, Grid } from '@material-ui/core'; + +export const ServiceDrawer = ({ + service, + expanded, +}: { + service: V1Service; + expanded?: boolean; +}) => { + return ( + { + return serviceObject.spec || {}; + }} + > + + + + {service.metadata?.name ?? 'unknown object'} + + + + + Service + + + + + ); +}; diff --git a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.test.tsx similarity index 55% rename from plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx rename to plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.test.tsx index 35b8f2143a..46172b4150 100644 --- a/plugins/kubernetes/src/components/ConfigMaps/ConfigMaps.test.tsx +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.test.tsx @@ -16,26 +16,22 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { ConfigMaps } from './ConfigMaps'; -import * as configmapFixture from './__fixtures__/configmap.json'; +import * as twoDeployFixture from './__fixtures__/2-services.json'; import { wrapInTestApp } from '@backstage/test-utils'; +import { ServicesAccordions } from './ServicesAccordions'; -describe('ConfigMaps', () => { - it('should render configmap', async () => { +describe('ServicesAccordions', () => { + it('should render 2 services', async () => { const { getByText } = render( wrapInTestApp( - , + , ), ); - // title - expect(getByText('dice-roller')).toBeInTheDocument(); - expect(getByText('Config Map')).toBeInTheDocument(); + expect(getByText('awesome-service-grpc')).toBeInTheDocument(); + expect(getByText('Type: ClusterIP')).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 + expect(getByText('awesome-service-pg')).toBeInTheDocument(); + expect(getByText('Type: ExternalName')).toBeInTheDocument(); }); }); diff --git a/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.tsx new file mode 100644 index 0000000000..c378f02658 --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServicesAccordions.tsx @@ -0,0 +1,123 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GroupedResponses } from '../../types/types'; +import React from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Grid, + Typography, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { V1Service } from '@kubernetes/client-node'; +import { StructuredMetadataTable } from '@backstage/core'; +import { ServiceDrawer } from './ServiceDrawer'; + +type ServiceSummaryProps = { + service: V1Service; +}; + +const ServiceSummary = ({ service }: ServiceSummaryProps) => { + return ( + + + + + + + + + + + + Type: {service.spec?.type ?? '?'} + + + + ); +}; + +type ServiceCardProps = { + service: V1Service; +}; + +const ServiceCard = ({ service }: ServiceCardProps) => { + const metadata: any = {}; + + if (service.status?.loadBalancer?.ingress?.length ?? -1 > 0) { + metadata.loadbalancer = service.status?.loadBalancer; + } + + if (service.spec?.type === 'ClusterIP') { + metadata.clusterIP = service.spec.clusterIP; + } + if (service.spec?.type === 'ExternalName') { + metadata.externalName = service.spec.externalName; + } + + return ( + + ); +}; + +type ServicesAccordionsProps = { + deploymentResources: GroupedResponses; +}; + +type ServiceAccordionProps = { + service: V1Service; +}; + +const ServiceAccordion = ({ service }: ServiceAccordionProps) => { + return ( + + }> + + + + + + + ); +}; + +export const ServicesAccordions = ({ + deploymentResources, +}: ServicesAccordionsProps) => { + return ( + + {deploymentResources.services.map((service, i) => ( + + + + ))} + + ); +}; diff --git a/plugins/kubernetes/src/components/ServicesAccordions/__fixtures__/2-services.json b/plugins/kubernetes/src/components/ServicesAccordions/__fixtures__/2-services.json new file mode 100644 index 0000000000..f982ebf164 --- /dev/null +++ b/plugins/kubernetes/src/components/ServicesAccordions/__fixtures__/2-services.json @@ -0,0 +1,83 @@ +{ + "services": [ + { + "metadata": { + "annotations": { + "artifact.spinnaker.io/location": "default", + "artifact.spinnaker.io/name": "awesome-service-grpc", + "artifact.spinnaker.io/type": "kubernetes/service", + "moniker.spinnaker.io/application": "awesome-service", + "moniker.spinnaker.io/cluster": "service awesome-service-grpc" + }, + "creationTimestamp": "2021-01-04T16:35:04.000Z", + "labels": { + "app": "awesome-service", + "app.kubernetes.io/managed-by": "spinnaker", + "app.kubernetes.io/name": "awesome-service" + }, + "name": "awesome-service-grpc", + "namespace": "default", + "resourceVersion": "548901649", + "selfLink": "/api/v1/namespaces/default/services/awesome-service-grpc", + "uid": "461cdcd7-8c61-4125-91f9-e03d745f2f2c" + }, + "spec": { + "clusterIP": "None", + "ports": [ + { + "name": "grpc", + "port": 1997, + "protocol": "TCP", + "targetPort": 1997 + } + ], + "selector": { + "app": "awesome-service" + }, + "sessionAffinity": "None", + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + }, + { + "metadata": { + "annotations": { + "artifact.spinnaker.io/location": "default", + "artifact.spinnaker.io/name": "awesome-service-pg", + "artifact.spinnaker.io/type": "kubernetes/service", + "moniker.spinnaker.io/application": "awesome-service", + "moniker.spinnaker.io/cluster": "service awesome-service-pg" + }, + "creationTimestamp": "2021-01-04T16:35:02.000Z", + "labels": { + "app": "awesome-service", + "app.kubernetes.io/managed-by": "spinnaker", + "app.kubernetes.io/name": "awesome-service" + }, + "name": "awesome-service-pg", + "namespace": "default", + "resourceVersion": "548901625", + "selfLink": "/api/v1/namespaces/default/services/awesome-service-pg", + "uid": "7d7ff8f2-6caa-4888-ae55-b6d41833ab92" + }, + "spec": { + "externalName": "10.244.0.5", + "ports": [ + { + "name": "pg", + "port": 5432, + "protocol": "TCP", + "targetPort": 5432 + } + ], + "sessionAffinity": "None", + "type": "ExternalName" + }, + "status": { + "loadBalancer": {} + } + } + ] +} diff --git a/plugins/kubernetes/src/components/DeploymentTables/index.ts b/plugins/kubernetes/src/components/ServicesAccordions/index.ts similarity index 90% rename from plugins/kubernetes/src/components/DeploymentTables/index.ts rename to plugins/kubernetes/src/components/ServicesAccordions/index.ts index dac38c5c81..a3c392c743 100644 --- a/plugins/kubernetes/src/components/DeploymentTables/index.ts +++ b/plugins/kubernetes/src/components/ServicesAccordions/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { DeploymentTables } from './DeploymentTables'; +export { ServicesAccordions } from './ServicesAccordions'; diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json new file mode 100644 index 0000000000..f07a6f7d69 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-bad.json @@ -0,0 +1,119 @@ +{ + "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" + }, + "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/error-detection/__fixtures__/deploy-healthy.json b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json new file mode 100644 index 0000000000..87a7e07b0d --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/deploy-healthy.json @@ -0,0 +1,92 @@ +{ + "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" + }, + "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 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json new file mode 100644 index 0000000000..23edee5a07 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-healthy.json @@ -0,0 +1,32 @@ +{ + "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" + }, + "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": 13, + "desiredReplicas": 14, + "currentCPUUtilizationPercentage": 30 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json new file mode 100644 index 0000000000..4466e7b4b1 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/hpa-maxed-out.json @@ -0,0 +1,32 @@ +{ + "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" + }, + "name": "dice-roller", + "namespace": "default", + "resourceVersion": "698957", + "selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller", + "uid": "a70c8a90-5605-4d7d-adea-05cfb8d9d446" + }, + "spec": { + "maxReplicas": 10, + "minReplicas": 5, + "scaleTargetRef": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "name": "dice-roller" + }, + "targetCPUUtilizationPercentage": 70 + }, + "status": { + "currentReplicas": 10, + "desiredReplicas": 10, + "currentCPUUtilizationPercentage": 100 + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json b/plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json new file mode 100644 index 0000000000..79acd2900d --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/pod-crashing.json @@ -0,0 +1,233 @@ +{ + "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" + }, + "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" + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json b/plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json new file mode 100644 index 0000000000..3fdd4deaab --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/pod-missing-cm.json @@ -0,0 +1,168 @@ +{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "creationTimestamp": "2021-01-06T14:33:54Z", + "generateName": "dice-roller-bad-cm-855bf85464-", + "labels": { + "app": "dice-roller-bad-cm", + "backstage.io/kubernetes-id": "dice-roller", + "pod-template-hash": "855bf85464" + }, + "name": "dice-roller-bad-cm-855bf85464-mg6xb", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "blockOwnerDeletion": true, + "controller": true, + "kind": "ReplicaSet", + "name": "dice-roller-bad-cm-855bf85464", + "uid": "9bc2a418-60eb-4dfc-9748-78cf49ea9863" + } + ], + "resourceVersion": "2457755284", + "selfLink": "/api/v1/namespaces/default/pods/dice-roller-bad-cm-855bf85464-mg6xb", + "uid": "5f257d3c-a16d-4ef1-9dc9-d11e321a640a" + }, + "spec": { + "containers": [ + { + "env": [ + { + "name": "SOME_ENV_VAR", + "valueFrom": { + "configMapKeyRef": { + "key": "some-key", + "name": "some-cm" + } + } + } + ], + "image": "nginx:1.14.2", + "imagePullPolicy": "IfNotPresent", + "name": "nginx", + "ports": [ + { + "containerPort": 80, + "protocol": "TCP" + } + ], + "resources": { + "limits": { + "cpu": "500m", + "memory": "128Mi" + }, + "requests": { + "cpu": "50m", + "memory": "64Mi" + } + }, + "securityContext": { + "allowPrivilegeEscalation": false + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "volumeMounts": [ + { + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount", + "name": "default-token-dbkkb", + "readOnly": true + } + ] + } + ], + "dnsPolicy": "ClusterFirst", + "enableServiceLinks": true, + "nodeName": "node1", + "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-dbkkb", + "secret": { + "defaultMode": 420, + "secretName": "default-token-dbkkb" + } + } + ] + }, + "status": { + "conditions": [ + { + "lastProbeTime": null, + "lastTransitionTime": "2021-01-06T14:33:54Z", + "status": "True", + "type": "Initialized" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-01-06T14:33:54Z", + "message": "containers with unready status: [nginx]", + "reason": "ContainersNotReady", + "status": "False", + "type": "Ready" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-01-06T14:33:54Z", + "message": "containers with unready status: [nginx]", + "reason": "ContainersNotReady", + "status": "False", + "type": "ContainersReady" + }, + { + "lastProbeTime": null, + "lastTransitionTime": "2021-01-06T14:33:54Z", + "status": "True", + "type": "PodScheduled" + } + ], + "containerStatuses": [ + { + "image": "nginx:1.14.2", + "imageID": "", + "lastState": {}, + "name": "nginx", + "ready": false, + "restartCount": 0, + "started": false, + "state": { + "waiting": { + "message": "configmap \"some-cm\" not found", + "reason": "CreateContainerConfigError" + } + } + } + ], + "hostIP": "10.1.33.73", + "phase": "Pending", + "podIP": "10.1.115.15", + "podIPs": [ + { + "ip": "10.1.115.15" + } + ], + "qosClass": "Burstable", + "startTime": "2021-01-06T14:33:54Z" + } +} diff --git a/plugins/kubernetes/src/error-detection/__fixtures__/pod.json b/plugins/kubernetes/src/error-detection/__fixtures__/pod.json new file mode 100644 index 0000000000..5092efc089 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/__fixtures__/pod.json @@ -0,0 +1,139 @@ +{ + "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" + }, + "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" + } +} diff --git a/plugins/kubernetes/src/error-detection/common.ts b/plugins/kubernetes/src/error-detection/common.ts new file mode 100644 index 0000000000..626eaf39d4 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/common.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DetectedError, + ErrorDetectable, + ErrorDetectableKind, + ErrorMapper, +} from './types'; + +// Run through the each error mapper for each object +// returning a deduplicated (mostly) result +export const detectErrorsInObjects = ( + objects: T[], + kind: ErrorDetectableKind, + clusterName: string, + errorMappers: ErrorMapper[], +): DetectedError[] => { + // Build up a map of errors + // key: the joined message produced by an error + // value: the error + const errors = new Map(); + + for (const object of objects) { + for (const errorMapper of errorMappers) { + if (errorMapper.errorExists(object)) { + const message = errorMapper.messageAccessor(object); + + // TODO This is not perfect as errors with uuid/hashes/date/times will not be caught by this + const dedupKey = message.join(''); + + const value = errors.get(dedupKey); + + const name = object.metadata?.name ?? 'unknown'; + + if (value !== undefined) { + // This gets translated into the Chip "+5 others" + // in the ErrorReporting component + // but we need to keep the names so we can easily + // find which objects owns the error later + value.names.push(name); + errors.set(dedupKey, value); + } else { + errors.set(dedupKey, { + cluster: clusterName, + kind: kind, + names: [name], + message: message, + severity: errorMapper.severity, + }); + } + } + } + } + + return Array.from(errors.values()); +}; diff --git a/plugins/kubernetes/src/error-detection/deployments.ts b/plugins/kubernetes/src/error-detection/deployments.ts new file mode 100644 index 0000000000..f70d4196cb --- /dev/null +++ b/plugins/kubernetes/src/error-detection/deployments.ts @@ -0,0 +1,49 @@ +import { DetectedError, ErrorMapper } from './types'; +import { V1Deployment } from '@kubernetes/client-node'; +import { detectErrorsInObjects } from './common'; + +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 deploymentErrorMappers: ErrorMapper[] = [ + { + // this is probably important + severity: 6, + errorExplanation: 'condition-message-present', + errorExists: deployment => { + return (deployment.status?.conditions ?? []) + .filter(c => c.status === 'False') + .some(c => c.message !== undefined); + }, + messageAccessor: deployment => { + return (deployment.status?.conditions ?? []) + .filter(c => c.status === 'False') + .filter(c => c.message !== undefined) + .map(c => c.message ?? ''); + }, + }, +]; + +export const detectErrorsInDeployments = ( + deployments: V1Deployment[], + clusterName: string, +): DetectedError[] => + detectErrorsInObjects( + deployments, + 'Deployment', + clusterName, + deploymentErrorMappers, + ); diff --git a/plugins/kubernetes/src/error-detection/error-detection.test.ts b/plugins/kubernetes/src/error-detection/error-detection.test.ts new file mode 100644 index 0000000000..8a4575c8be --- /dev/null +++ b/plugins/kubernetes/src/error-detection/error-detection.test.ts @@ -0,0 +1,282 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + V1Pod, + V1Deployment, + V1HorizontalPodAutoscaler, +} from '@kubernetes/client-node'; +import { detectErrors } from './error-detection'; +import * as healthyPod from './__fixtures__/pod.json'; +import * as podMissingCm from './__fixtures__/pod-missing-cm.json'; +import * as crashingPod from './__fixtures__/pod-crashing.json'; +import * as healthyDeploy from './__fixtures__/deploy-healthy.json'; +import * as failingDeploy from './__fixtures__/deploy-bad.json'; +import * as healthyHpa from './__fixtures__/hpa-healthy.json'; +import * as maxedOutHpa from './__fixtures__/hpa-maxed-out.json'; +import { + FetchResponse, + ObjectsByEntityResponse, +} from '@backstage/plugin-kubernetes-backend'; + +const CLUSTER_NAME = 'cluster-a'; + +const oneItem = (value: FetchResponse): ObjectsByEntityResponse => { + return { + items: [ + { + cluster: { name: CLUSTER_NAME }, + errors: [], + resources: [value], + }, + ], + }; +}; + +const onePod = (pod: V1Pod): ObjectsByEntityResponse => { + return oneItem({ + type: 'pods', + resources: [pod], + }); +}; + +const oneDeployment = (deployment: V1Deployment): ObjectsByEntityResponse => { + return oneItem({ + type: 'deployments', + resources: [deployment], + }); +}; + +const oneHpa = (hpa: V1HorizontalPodAutoscaler): ObjectsByEntityResponse => { + return oneItem({ + type: 'horizontalpodautoscalers', + resources: [hpa], + }); +}; + +describe('detectErrors', () => { + it('should return errors from different clusters', () => { + const result = detectErrors({ + items: [ + { + cluster: { name: 'cluster-a' }, + errors: [], + resources: [ + { + type: 'pods', + resources: [crashingPod as any], + }, + ], + }, + { + cluster: { name: 'cluster-b' }, + errors: [], + resources: [ + { + type: 'horizontalpodautoscalers', + resources: [maxedOutHpa as any], + }, + ], + }, + { + cluster: { name: 'cluster-c' }, + errors: [], + resources: [ + { + type: 'deployments', + resources: [healthyDeploy as any], + }, + ], + }, + ], + }); + + expect(result.size).toBe(3); + + const errorsFromClusterA = result.get('cluster-a'); + const errorsFromClusterB = result.get('cluster-b'); + const errorsFromClusterC = result.get('cluster-c'); + + expect(errorsFromClusterA).toBeDefined(); + expect(errorsFromClusterA).toHaveLength(4); + + expect(errorsFromClusterB).toBeDefined(); + expect(errorsFromClusterB).toHaveLength(1); + + expect(errorsFromClusterC).toBeDefined(); + expect(errorsFromClusterC).toHaveLength(0); + }); + + it('should detect no errors in healthy pod', () => { + const result = detectErrors(onePod(healthyPod as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(0); + }); + it('should detect errors in crashing pod', () => { + const result = detectErrors(onePod(crashingPod as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(4); + + const [err1, err2, err3, err4] = errors ?? []; + + expect(err1).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'container=other-side-car restarted 38 times', + 'container=side-car restarted 38 times', + ], + names: ['dice-roller-canary-7d64cd756c-55rfq'], + severity: 4, + }); + + expect(err2).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'containers with unready status: [side-car other-side-car]', + 'containers with unready status: [side-car other-side-car]', + ], + names: ['dice-roller-canary-7d64cd756c-55rfq'], + severity: 5, + }); + + expect(err3).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'back-off 5m0s restarting failed container=other-side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)', + 'back-off 5m0s restarting failed container=side-car pod=dice-roller-canary-7d64cd756c-55rfq_default(65ad28e3-5d51-4b4b-9bf8-4cb069803034)', + ], + names: ['dice-roller-canary-7d64cd756c-55rfq'], + severity: 6, + }); + + expect(err4).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'container=other-side-car exited with error code (1)', + 'container=side-car exited with error code (1)', + ], + names: ['dice-roller-canary-7d64cd756c-55rfq'], + severity: 4, + }); + }); + it('should detect errors in pod with missing Config Map', () => { + const result = detectErrors(onePod(podMissingCm as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(2); + + const [err1, err2] = errors ?? []; + + expect(err1).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: [ + 'containers with unready status: [nginx]', + 'containers with unready status: [nginx]', + ], + names: ['dice-roller-bad-cm-855bf85464-mg6xb'], + severity: 5, + }); + + expect(err2).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Pod', + message: ['configmap "some-cm" not found'], + names: ['dice-roller-bad-cm-855bf85464-mg6xb'], + severity: 6, + }); + }); + it('should detect no errors in healthy deployment', () => { + const result = detectErrors(oneDeployment(healthyDeploy as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(0); + }); + it('should detect in deployment which cant progress', () => { + const result = detectErrors(oneDeployment(failingDeploy as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + + const [err1] = errors ?? []; + + expect(err1).toStrictEqual({ + cluster: 'cluster-a', + kind: 'Deployment', + message: ['Deployment does not have minimum availability.'], + names: ['dice-roller-canary'], + severity: 6, + }); + }); + it('should detect no errors in healthy hpa', () => { + const result = detectErrors(oneHpa(healthyHpa as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(0); + }); + it('should detect in maxed out hpa', () => { + const result = detectErrors(oneHpa(maxedOutHpa as any)); + + expect(result.size).toBe(1); + + const errors = result.get(CLUSTER_NAME); + + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + + const [err1] = errors ?? []; + + expect(err1).toStrictEqual({ + cluster: 'cluster-a', + kind: 'HorizontalPodAutoscaler', + message: [ + 'Current number of replicas (10) is equal to the configured max number of replicas (10)', + ], + names: ['dice-roller'], + severity: 8, + }); + }); +}); diff --git a/plugins/kubernetes/src/error-detection/error-detection.ts b/plugins/kubernetes/src/error-detection/error-detection.ts new file mode 100644 index 0000000000..a44747969d --- /dev/null +++ b/plugins/kubernetes/src/error-detection/error-detection.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DetectedError, DetectedErrorsByCluster } from './types'; +import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-backend'; +import { groupResponses } from '../utils/response'; +import { detectErrorsInPods } from './pods'; +import { detectErrorsInDeployments } from './deployments'; +import { detectErrorsInHpa } from './hpas'; + +// For each cluster try to find errors in each of the object types provided +// returning a map of cluster names to errors in that cluster +export const detectErrors = ( + objects: ObjectsByEntityResponse, +): DetectedErrorsByCluster => { + const errors: DetectedErrorsByCluster = new Map(); + + for (const clusterResponse of objects.items) { + let clusterErrors: DetectedError[] = []; + + const groupedResponses = groupResponses(clusterResponse.resources); + + clusterErrors = clusterErrors.concat( + detectErrorsInPods(groupedResponses.pods, clusterResponse.cluster.name), + ); + + clusterErrors = clusterErrors.concat( + detectErrorsInDeployments( + groupedResponses.deployments, + clusterResponse.cluster.name, + ), + ); + + clusterErrors = clusterErrors.concat( + detectErrorsInHpa( + groupedResponses.horizontalPodAutoscalers, + clusterResponse.cluster.name, + ), + ); + + errors.set(clusterResponse.cluster.name, clusterErrors); + } + + return errors; +}; diff --git a/plugins/kubernetes/src/error-detection/hpas.ts b/plugins/kubernetes/src/error-detection/hpas.ts new file mode 100644 index 0000000000..53c429b2f2 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/hpas.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 { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { DetectedError, ErrorMapper } from './types'; +import { detectErrorsInObjects } from './common'; + +const hpaErrorMappers: ErrorMapper[] = [ + { + // this is probably important + severity: 8, + errorExplanation: 'hpa-max-current-replicas', + errorExists: hpa => { + return (hpa.spec?.maxReplicas ?? -1) === hpa.status?.currentReplicas; + }, + messageAccessor: hpa => { + return [ + `Current number of replicas (${ + hpa.status?.currentReplicas + }) is equal to the configured max number of replicas (${ + hpa.spec?.maxReplicas ?? -1 + })`, + ]; + }, + }, +]; + +export const detectErrorsInHpa = ( + hpas: V1HorizontalPodAutoscaler[], + clusterName: string, +): DetectedError[] => + detectErrorsInObjects( + hpas, + 'HorizontalPodAutoscaler', + clusterName, + hpaErrorMappers, + ); diff --git a/plugins/kubernetes/src/error-detection/index.ts b/plugins/kubernetes/src/error-detection/index.ts new file mode 100644 index 0000000000..69a4d11851 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/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 type { DetectedError, DetectedErrorsByCluster } from './types'; +export { detectErrors } from './error-detection'; diff --git a/plugins/kubernetes/src/error-detection/pods.ts b/plugins/kubernetes/src/error-detection/pods.ts new file mode 100644 index 0000000000..8b990d0d52 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/pods.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 { V1Pod } from '@kubernetes/client-node'; +import { totalRestarts } from '../utils/pod'; +import { DetectedError, ErrorMapper } from './types'; +import { detectErrorsInObjects } from './common'; + +const podErrorMappers: ErrorMapper[] = [ + { + severity: 5, + errorExplanation: 'status-message', + errorExists: pod => { + return pod.status?.message !== undefined; + }, + messageAccessor: pod => { + return [pod.status?.message ?? '']; + }, + }, + { + severity: 4, + errorExplanation: 'containers-restarting', + errorExists: pod => { + // TODO magic number + return totalRestarts(pod) > 3; + }, + messageAccessor: pod => { + return (pod.status?.containerStatuses ?? []) + .filter(cs => cs.restartCount > 0) + .map(cs => `container=${cs.name} restarted ${cs.restartCount} times`); + }, + }, + { + severity: 5, + errorExplanation: 'condition-message-present', + errorExists: pod => { + return (pod.status?.conditions ?? []).some(c => c.message !== undefined); + }, + messageAccessor: pod => { + return (pod.status?.conditions ?? []) + .filter(c => c.message !== undefined) + .map(c => c.message ?? ''); + }, + }, + { + severity: 6, + errorExplanation: 'container-waiting', + errorExists: pod => { + return (pod.status?.containerStatuses ?? []).some( + cs => cs.state?.waiting?.message !== undefined, + ); + }, + messageAccessor: pod => { + return (pod.status?.containerStatuses ?? []) + .filter(cs => cs.state?.waiting?.message !== undefined) + .map(cs => cs.state?.waiting?.message ?? ''); + }, + }, + { + severity: 4, + errorExplanation: 'container-last-state-error', + errorExists: pod => { + return (pod.status?.containerStatuses ?? []).some( + cs => (cs.lastState?.terminated?.reason ?? '') === 'Error', + ); + }, + messageAccessor: pod => { + return (pod.status?.containerStatuses ?? []) + .filter(cs => (cs.lastState?.terminated?.reason ?? '') === 'Error') + .map( + cs => + `container=${cs.name} exited with error code (${cs.lastState?.terminated?.exitCode})`, + ); + }, + }, +]; + +export const detectErrorsInPods = ( + pods: V1Pod[], + clusterName: string, +): DetectedError[] => + detectErrorsInObjects(pods, 'Pod', clusterName, podErrorMappers); diff --git a/plugins/kubernetes/src/error-detection/types.ts b/plugins/kubernetes/src/error-detection/types.ts new file mode 100644 index 0000000000..817911ce40 --- /dev/null +++ b/plugins/kubernetes/src/error-detection/types.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. + */ + +// Higher is more sever, but it's relative +import { + V1Deployment, + V1HorizontalPodAutoscaler, + V1Pod, +} from '@kubernetes/client-node'; + +export type ErrorSeverity = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; + +export type ErrorDetectable = V1Pod | V1Deployment | V1HorizontalPodAutoscaler; + +export type ErrorDetectableKind = + | 'Pod' + | 'Deployment' + | 'HorizontalPodAutoscaler'; + +export type DetectedErrorsByCluster = Map; + +export interface DetectedError { + severity: ErrorSeverity; + cluster: string; + kind: ErrorDetectableKind; + names: string[]; + message: string[]; +} + +export interface ErrorMapper { + severity: ErrorSeverity; + errorExplanation: string; + errorExists: (object: T) => boolean; + messageAccessor: (object: T) => string[]; +} diff --git a/plugins/kubernetes/src/index.ts b/plugins/kubernetes/src/index.ts index 8b5969666c..eca6b068f1 100644 --- a/plugins/kubernetes/src/index.ts +++ b/plugins/kubernetes/src/index.ts @@ -13,5 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin } from './plugin'; +export { + kubernetesPlugin, + kubernetesPlugin as plugin, + EntityKubernetesContent, +} from './plugin'; export { Router } from './Router'; diff --git a/plugins/kubernetes/src/plugin.test.ts b/plugins/kubernetes/src/plugin.test.ts index 20c50a813a..f176b7f451 100644 --- a/plugins/kubernetes/src/plugin.test.ts +++ b/plugins/kubernetes/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { plugin } from './plugin'; +import { kubernetesPlugin } from './plugin'; describe('kubernetes', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(kubernetesPlugin).toBeDefined(); }); }); diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index cff9e1468b..af29df3dcc 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -19,6 +19,7 @@ import { createRouteRef, discoveryApiRef, googleAuthApiRef, + createRoutableExtension, } from '@backstage/core'; import { KubernetesBackendClient } from './api/KubernetesBackendClient'; import { kubernetesApiRef } from './api/types'; @@ -30,7 +31,7 @@ export const rootCatalogKubernetesRouteRef = createRouteRef({ title: 'Kubernetes', }); -export const plugin = createPlugin({ +export const kubernetesPlugin = createPlugin({ id: 'kubernetes', apis: [ createApiFactory({ @@ -47,4 +48,14 @@ export const plugin = createPlugin({ }, }), ], + routes: { + entityContent: rootCatalogKubernetesRouteRef, + }, }); + +export const EntityKubernetesContent = kubernetesPlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.Router), + mountPoint: rootCatalogKubernetesRouteRef, + }), +); diff --git a/plugins/kubernetes/src/types/types.ts b/plugins/kubernetes/src/types/types.ts index 09bff511a7..ab12730f72 100644 --- a/plugins/kubernetes/src/types/types.ts +++ b/plugins/kubernetes/src/types/types.ts @@ -14,10 +14,25 @@ * limitations under the License. */ -import { V1Deployment, V1Pod, V1ReplicaSet } from '@kubernetes/client-node'; +import { + V1Deployment, + V1Pod, + V1ReplicaSet, + V1HorizontalPodAutoscaler, + V1Service, + V1ConfigMap, + ExtensionsV1beta1Ingress, +} from '@kubernetes/client-node'; -export interface DeploymentTriple { +export interface DeploymentResources { pods: V1Pod[]; replicaSets: V1ReplicaSet[]; deployments: V1Deployment[]; + horizontalPodAutoscalers: V1HorizontalPodAutoscaler[]; +} + +export interface GroupedResponses extends DeploymentResources { + services: V1Service[]; + configMaps: V1ConfigMap[]; + ingresses: ExtensionsV1beta1Ingress[]; } diff --git a/plugins/kubernetes/src/utils/pod.tsx b/plugins/kubernetes/src/utils/pod.tsx new file mode 100644 index 0000000000..59e18e6ecb --- /dev/null +++ b/plugins/kubernetes/src/utils/pod.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 { V1Pod, V1PodCondition } from '@kubernetes/client-node'; +import React, { Fragment, ReactNode } from 'react'; +import { + StatusAborted, + StatusError, + StatusOK, + SubvalueCell, +} from '@backstage/core'; +import { Chip } from '@material-ui/core'; +import { V1DeploymentCondition } from '@kubernetes/client-node/dist/gen/model/v1DeploymentCondition'; + +export const imageChips = (pod: V1Pod): ReactNode => { + const containerStatuses = pod.status?.containerStatuses ?? []; + const images = containerStatuses.map((cs, i) => { + return ; + }); + + return
{images}
; +}; + +export const containersReady = (pod: V1Pod): string => { + const containerStatuses = pod.status?.containerStatuses ?? []; + const containersReadyItem = containerStatuses.filter(cs => cs.ready).length; + + return `${containersReadyItem}/${containerStatuses.length}`; +}; + +export const totalRestarts = (pod: V1Pod): number => { + const containerStatuses = pod.status?.containerStatuses ?? []; + return containerStatuses?.reduce((a, b) => a + b.restartCount, 0); +}; + +export const containerStatuses = (pod: V1Pod): ReactNode => { + const containerStatusesItem = pod.status?.containerStatuses ?? []; + const errors = containerStatusesItem.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; +}; + +export const renderCondition = ( + condition: V1PodCondition | V1DeploymentCondition, +): [string, ReactNode] => { + const status = condition.status; + + if (status === 'True') { + return [condition.type, True]; + } else if (status === 'False') { + return [ + condition.type, + False} + subvalue={condition.message ?? ''} + />, + ]; + } + return [condition.type, ]; +}; diff --git a/plugins/kubernetes/src/utils/response.ts b/plugins/kubernetes/src/utils/response.ts new file mode 100644 index 0000000000..54b319a6ef --- /dev/null +++ b/plugins/kubernetes/src/utils/response.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FetchResponse } from '@backstage/plugin-kubernetes-backend'; +import { GroupedResponses } from '../types/types'; + +// TODO this could probably be a lodash groupBy +export const groupResponses = ( + fetchResponse: FetchResponse[], +): GroupedResponses => { + 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, + ); +}; diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index 42035d1209..8fec8887c3 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,60 @@ # @backstage/plugin-lighthouse +## 0.2.9 + +### Patch Changes + +- a5628df40: Migrate to new composability API, exporting the plugin instance as `lighthousePlugin`, the top-level page as `LighthousePage`, the entity card as `EntityLastLighthouseAuditCard`, and the entity content as `EntityLighthouseContent`. +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.2.8 + +### Patch Changes + +- debf359b5: Fix display of floating point precision errors in card category scores +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + +## 0.2.7 + +### Patch Changes + +- cf7df3b1f: Strip trailing slash from url when creating a new audit. This change prevents duplicate audits from being displayed in the audit list. +- a6f9dca0d: Remove dependency on `@backstage/core-api`. No plugin should ever depend on that package; it's an internal concern whose important bits are re-exported by `@backstage/core` which is the public facing dependency to use. +- Updated dependencies [f3b064e1c] +- Updated dependencies [c00488983] +- Updated dependencies [265a7ab30] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/plugin-catalog@0.2.11 + - @backstage/core@0.4.4 + ## 0.2.6 ### Patch Changes diff --git a/plugins/lighthouse/dev/index.tsx b/plugins/lighthouse/dev/index.tsx index bf761965f1..1ea54e5871 100644 --- a/plugins/lighthouse/dev/index.tsx +++ b/plugins/lighthouse/dev/index.tsx @@ -15,11 +15,11 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { lighthousePlugin } from '../src/plugin'; import { lighthouseApiRef, LighthouseRestApi } from '../src'; createDevApp() - .registerPlugin(plugin) + .registerPlugin(lighthousePlugin) .registerApi({ api: lighthouseApiRef, deps: {}, diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 7bb1427930..07e1ad8475 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.2.6", + "version": "0.2.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,12 +31,11 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/core": "^0.4.3", - "@backstage/core-api": "^0.2.6", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/theme": "^0.2.2", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,8 +46,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/lighthouse/src/Router.tsx b/plugins/lighthouse/src/Router.tsx index d264f3bc1b..643d30cdc2 100644 --- a/plugins/lighthouse/src/Router.tsx +++ b/plugins/lighthouse/src/Router.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { Route, Routes } from 'react-router-dom'; -import { createAuditRouteRef, rootRouteRef, viewAuditRouteRef } from './plugin'; +import { useEntity } from '@backstage/plugin-catalog-react'; import AuditList from './components/AuditList'; import AuditView, { AuditViewContent } from './components/AuditView'; import CreateAudit, { CreateAuditContent } from './components/CreateAudit'; @@ -25,32 +25,38 @@ import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../constants'; import { AuditListForEntity } from './components/AuditList/AuditListForEntity'; import { MissingAnnotationEmptyState } from '@backstage/core'; -export const isPluginApplicableToEntity = (entity: Entity) => +export const isLighthouseAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION]); export const Router = () => ( - } /> - } /> - } /> + } /> + } /> + } /> ); -export const EmbeddedRouter = ({ entity }: { entity: Entity }) => - !isPluginApplicableToEntity(entity) ? ( - - ) : ( +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; +}; + +export const EmbeddedRouter = (_props: Props) => { + const { entity } = useEntity(); + + if (!isLighthouseAvailable(entity)) { + return ( + + ); + } + + return ( - } /> - } - /> - } - /> + } /> + } /> + } /> ); +}; diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx index 15a4ba3255..483d630db8 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -14,23 +14,22 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { lighthouseApiRef, LighthouseRestApi, WebsiteListResponse, } from '../../api'; - -import * as data from '../../__fixtures__/website-list-response.json'; -import { EntityContext } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; -import { AuditListForEntity } from './AuditListForEntity'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; -import { MemoryRouter } from 'react-router-dom'; import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; +import * as data from '../../__fixtures__/website-list-response.json'; +import { AuditListForEntity } from './AuditListForEntity'; jest.mock('../../hooks/useWebsiteForEntity', () => ({ useWebsiteForEntity: jest.fn(), diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index 0fb66a317b..8a59869266 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -25,7 +25,6 @@ import { } from '../../utils'; import { Link, generatePath } from 'react-router-dom'; import AuditStatusIcon from '../AuditStatusIcon'; -import { viewAuditRouteRef } from '../../plugin'; const columns: TableColumn[] = [ { @@ -99,11 +98,7 @@ export const AuditListTable = ({ items }: { items: Website[] }) => { return { websiteUrl: ( - + {website.url} ), diff --git a/plugins/lighthouse/src/components/AuditList/index.test.tsx b/plugins/lighthouse/src/components/AuditList/index.test.tsx index 5fdc4781ce..4657066249 100644 --- a/plugins/lighthouse/src/components/AuditList/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.test.tsx @@ -162,7 +162,7 @@ describe('AuditList', () => { , ), ); - const element = await rendered.findByTestId('error-message'); + const element = await rendered.findByText(/Could not load audit list./); expect(element).toBeInTheDocument(); }); }); diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index b022482da4..8d7f769909 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -17,7 +17,6 @@ import React, { useState, useMemo, ReactNode } from 'react'; import { useLocalStorage, useAsync } from 'react-use'; import { useNavigate } from 'react-router-dom'; import { Grid, Button } from '@material-ui/core'; -import Alert from '@material-ui/lab/Alert'; import Pagination from '@material-ui/lab/Pagination'; import { InfoCard, @@ -28,6 +27,7 @@ import { HeaderLabel, Progress, useApi, + WarningPanel, } from '@backstage/core'; import { lighthouseApiRef } from '../../api'; @@ -35,7 +35,6 @@ import { useQuery } from '../../utils'; import LighthouseSupportButton from '../SupportButton'; import LighthouseIntro, { LIGHTHOUSE_INTRO_LOCAL_STORAGE } from '../Intro'; import AuditListTable from './AuditListTable'; -import { createAuditRouteRef } from '../../plugin'; export const LIMIT = 10; @@ -86,9 +85,9 @@ const AuditList = () => { content = ; } else if (error) { content = ( - + {error.message} - + ); } @@ -110,7 +109,7 @@ const AuditList = () => { diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx index 2a99339a84..2be284183c 100644 --- a/plugins/lighthouse/src/components/AuditView/index.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -47,7 +47,6 @@ import { lighthouseApiRef, Website, Audit } from '../../api'; import AuditStatusIcon from '../AuditStatusIcon'; import LighthouseSupportButton from '../SupportButton'; import { formatTime } from '../../utils'; -import { viewAuditRouteRef, createAuditRouteRef } from '../../plugin'; const useStyles = makeStyles({ contentGrid: { @@ -78,12 +77,7 @@ const AuditLinkList = ({ audits = [], selectedId }: AuditLinkListProps) => ( button component={Link} replace - to={resolvePath( - generatePath(viewAuditRouteRef.path, { - id: audit.id, - }), - '../../', - )} + to={resolvePath(generatePath('audit/:id', { id: audit.id }), '../../')} > @@ -163,7 +157,7 @@ export const AuditViewContent = () => { ); } - let createAuditButtonUrl = createAuditRouteRef.path; + let createAuditButtonUrl = 'create-audit'; if (value?.url) { createAuditButtonUrl += `?url=${encodeURIComponent(value.url)}`; } diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx index 20b3b9c072..1322736321 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx @@ -14,21 +14,21 @@ * limitations under the License. */ -import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { AuditCompleted, LighthouseCategoryId, WebsiteListResponse, } from '../../api'; -import { EntityContext } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; -import { LastLighthouseAuditCard } from './LastLighthouseAuditCard'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; -import { MemoryRouter } from 'react-router-dom'; import * as data from '../../__fixtures__/website-list-response.json'; +import { LastLighthouseAuditCard } from './LastLighthouseAuditCard'; jest.mock('../../hooks/useWebsiteForEntity', () => ({ useWebsiteForEntity: jest.fn(), @@ -38,7 +38,7 @@ const websiteListResponse = data as WebsiteListResponse; let entityWebsite = websiteListResponse.items[2]; describe('', () => { - const asPercentage = (fraction: number) => `${fraction * 100}%`; + const asPercentage = (fraction: number) => `${Math.round(fraction * 100)}%`; beforeEach(() => { (useWebsiteForEntity as jest.Mock).mockReturnValue({ diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx index 0bb83077ce..d30b9a3312 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx @@ -27,7 +27,7 @@ import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; import AuditStatusIcon from '../AuditStatusIcon'; const LighthouseCategoryScoreStatus = ({ score }: { score: number }) => { - const scoreAsPercentage = score * 100; + const scoreAsPercentage = Math.round(score * 100); switch (true) { case scoreAsPercentage >= 90: return ( diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx index 18f2c30a6e..8f514eb54a 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx @@ -79,7 +79,7 @@ export const CreateAuditContent = () => { // TODO use the id from the response to redirect to the audit page for that id when // FAILED and RUNNING audits are supported await lighthouseApi.triggerAudit({ - url, + url: url.replace(/\/$/, ''), options: { lighthouseConfig: { settings: { diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index 7f5925abf8..e22a64bdc1 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core-api'; -import { lighthouseApiRef, WebsiteListResponse } from '../api'; -import { useWebsiteForEntity } from './useWebsiteForEntity'; -import { EntityContext } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { PropsWithChildren } from 'react'; +import { lighthouseApiRef, WebsiteListResponse } from '../api'; import * as data from '../__fixtures__/website-list-response.json'; +import { useWebsiteForEntity } from './useWebsiteForEntity'; const websiteListResponse = data as WebsiteListResponse; const website = websiteListResponse.items[0]; diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts index c52e38f473..e6713cf320 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog'; -import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants'; -import { errorApiRef, useApi } from '@backstage/core-api'; -import { lighthouseApiRef } from '../api'; +import { errorApiRef, useApi } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; +import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants'; +import { lighthouseApiRef } from '../api'; // For the sake of simplicity we assume that an entity has only one website url. This is to avoid encoding a list // type in an annotation which is a plain string. diff --git a/plugins/lighthouse/src/index.ts b/plugins/lighthouse/src/index.ts index 64fe2f8cc0..bf51bbbf35 100644 --- a/plugins/lighthouse/src/index.ts +++ b/plugins/lighthouse/src/index.ts @@ -14,7 +14,18 @@ * limitations under the License. */ -export { plugin } from './plugin'; -export { Router, isPluginApplicableToEntity, EmbeddedRouter } from './Router'; +export { + lighthousePlugin, + lighthousePlugin as plugin, + LighthousePage, + EntityLighthouseContent, + EntityLastLighthouseAuditCard, +} from './plugin'; +export { + Router, + isLighthouseAvailable as isPluginApplicableToEntity, + isLighthouseAvailable, + EmbeddedRouter, +} from './Router'; export * from './api'; export * from './components/Cards'; diff --git a/plugins/lighthouse/src/plugin.test.ts b/plugins/lighthouse/src/plugin.test.ts index 70b1844ec2..642f438e03 100644 --- a/plugins/lighthouse/src/plugin.test.ts +++ b/plugins/lighthouse/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { lighthousePlugin } from './plugin'; describe('lighthouse', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(lighthousePlugin).toBeDefined(); }); }); diff --git a/plugins/lighthouse/src/plugin.ts b/plugins/lighthouse/src/plugin.ts index e9055b4db7..c2a8521da7 100644 --- a/plugins/lighthouse/src/plugin.ts +++ b/plugins/lighthouse/src/plugin.ts @@ -19,6 +19,8 @@ import { createRouteRef, createApiFactory, configApiRef, + createRoutableExtension, + createComponentExtension, } from '@backstage/core'; import { lighthouseApiRef, LighthouseRestApi } from './api'; @@ -37,7 +39,11 @@ export const createAuditRouteRef = createRouteRef({ title: 'Create Lighthouse Audit', }); -export const plugin = createPlugin({ +export const entityContentRouteRef = createRouteRef({ + title: 'Lighthouse Entity Content', +}); + +export const lighthousePlugin = createPlugin({ id: 'lighthouse', apis: [ createApiFactory({ @@ -46,4 +52,31 @@ export const plugin = createPlugin({ factory: ({ configApi }) => LighthouseRestApi.fromConfig(configApi), }), ], + routes: { + root: createAuditRouteRef, + entityContent: entityContentRouteRef, + }, }); + +export const LighthousePage = lighthousePlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); + +export const EntityLighthouseContent = lighthousePlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.EmbeddedRouter), + mountPoint: entityContentRouteRef, + }), +); + +export const EntityLastLighthouseAuditCard = lighthousePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/Cards').then(m => m.LastLighthouseAuditCard), + }, + }), +); diff --git a/plugins/newrelic/CHANGELOG.md b/plugins/newrelic/CHANGELOG.md index 60a174b28e..6eae73bc86 100644 --- a/plugins/newrelic/CHANGELOG.md +++ b/plugins/newrelic/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/plugin-newrelic +## 0.2.4 + +### Patch Changes + +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [54c7d02f7] + - @backstage/core@0.6.0 + - @backstage/theme@0.2.3 + +## 0.2.3 + +### Patch Changes + +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] + - @backstage/core@0.5.0 + ## 0.2.2 ### Patch Changes diff --git a/plugins/newrelic/dev/index.tsx b/plugins/newrelic/dev/index.tsx index 812a5585d4..9ca421f94a 100644 --- a/plugins/newrelic/dev/index.tsx +++ b/plugins/newrelic/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { newRelicPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(newRelicPlugin).render(); diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 03b2d83d65..ccd43c42c8 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-newrelic", - "version": "0.2.2", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/core": "^0.6.0", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,8 +41,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/newrelic/src/index.ts b/plugins/newrelic/src/index.ts index 3a0a0fe2d3..aa4e990d6c 100644 --- a/plugins/newrelic/src/index.ts +++ b/plugins/newrelic/src/index.ts @@ -14,4 +14,8 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + newRelicPlugin, + newRelicPlugin as plugin, + NewRelicPage, +} from './plugin'; diff --git a/plugins/newrelic/src/plugin.test.ts b/plugins/newrelic/src/plugin.test.ts index 17429a95e0..f2e8fde924 100644 --- a/plugins/newrelic/src/plugin.test.ts +++ b/plugins/newrelic/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { newRelicPlugin } from './plugin'; describe('newrelic', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(newRelicPlugin).toBeDefined(); }); }); diff --git a/plugins/newrelic/src/plugin.ts b/plugins/newrelic/src/plugin.ts index 5f5ba88617..d89aa813e9 100644 --- a/plugins/newrelic/src/plugin.ts +++ b/plugins/newrelic/src/plugin.ts @@ -19,6 +19,7 @@ import { createPlugin, createRouteRef, discoveryApiRef, + createRoutableExtension, } from '@backstage/core'; import { NewRelicClient, newRelicApiRef } from './api'; import NewRelicComponent from './components/NewRelicComponent'; @@ -28,7 +29,7 @@ export const rootRouteRef = createRouteRef({ title: 'newrelic', }); -export const plugin = createPlugin({ +export const newRelicPlugin = createPlugin({ id: 'newrelic', apis: [ createApiFactory({ @@ -40,4 +41,15 @@ export const plugin = createPlugin({ register({ router }) { router.addRoute(rootRouteRef, NewRelicComponent); }, + routes: { + root: rootRouteRef, + }, }); + +export const NewRelicPage = newRelicPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/NewRelicComponent').then(m => m.default), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md index 13025d8e83..6101685e1d 100644 --- a/plugins/org/CHANGELOG.md +++ b/plugins/org/CHANGELOG.md @@ -1,5 +1,65 @@ # @backstage/plugin-org +## 0.3.5 + +### Patch Changes + +- 7fc89bae2: Display owner and system as entity page links in the tables of the `api-docs` + plugin. + + Move `isOwnerOf` and `getEntityRelations` from `@backstage/plugin-catalog` to + `@backstage/plugin-catalog-react` and export it from there to use it by other + plugins. + +- 0269f4fd9: Migrate to new composability API, exporting the plugin instance as `orgPlugin`, and the entity cards as `EntityGroupProfileCard`, `EntityMembersListCard`, `EntityOwnershipCard`, and `EntityUserProfileCard`. +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.3.4 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + +## 0.3.3 + +### Patch Changes + +- f573cf368: Fixed - normalizing strings for comparison when ignoring when one is in low case. +- Updated dependencies [f3b064e1c] +- Updated dependencies [c00488983] +- Updated dependencies [265a7ab30] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/catalog-model@0.6.1 + - @backstage/plugin-catalog@0.2.11 + - @backstage/core@0.4.4 + ## 0.3.2 ### Patch Changes diff --git a/plugins/org/dev/index.tsx b/plugins/org/dev/index.tsx index 264d6f801f..01951eaf03 100644 --- a/plugins/org/dev/index.tsx +++ b/plugins/org/dev/index.tsx @@ -14,6 +14,6 @@ * limitations under the License. */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { orgPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(orgPlugin).render(); diff --git a/plugins/org/package.json b/plugins/org/package.json index 391311454b..0491554f77 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-org", - "version": "0.3.2", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,10 +20,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -33,8 +33,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 779cb1d92d..2f0f1e2e16 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -21,7 +21,7 @@ import { RELATION_PARENT_OF, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { entityRouteParams } from '@backstage/plugin-catalog'; +import { useEntity, entityRouteParams } from '@backstage/plugin-catalog-react'; import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; import AccountTreeIcon from '@material-ui/icons/AccountTree'; import EmailIcon from '@material-ui/icons/Email'; @@ -33,26 +33,29 @@ import { generatePath, Link as RouterLink } from 'react-router-dom'; const GroupLink = ({ groupName, index = 0, - entity, }: { groupName: string; index?: number; - entity: Entity; -}) => ( - <> - {index >= 1 ? ', ' : ''} - - [{groupName}] - - -); + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; +}) => { + const { entity } = useEntity(); + return ( + <> + {index >= 1 ? ', ' : ''} + + [{groupName}] + + + ); +}; const CardTitle = ({ title }: { title: string }) => ( @@ -61,24 +64,25 @@ const CardTitle = ({ title }: { title: string }) => ( ); export const GroupProfileCard = ({ - entity: group, variant, }: { - entity: GroupEntity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: GroupEntity; variant: string; }) => { + const group = useEntity().entity as GroupEntity; const { metadata: { name, description }, spec: { profile }, } = group; const parent = group?.relations ?.filter(r => r.type === RELATION_CHILD_OF) - ?.map(group => group.target.name) + ?.map(groupItem => groupItem.target.name) .toString(); const childrens = group?.relations ?.filter(r => r.type === RELATION_PARENT_OF) - ?.map(group => group.target.name); + ?.map(groupItem => groupItem.target.name); const displayName = profile?.displayName ?? name; diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx index 535b2acd0e..28c55b376b 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -14,11 +14,15 @@ * limitations under the License. */ +import { Entity, GroupEntity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; import React from 'react'; -import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; -import { Entity, GroupEntity } from '@backstage/catalog-model'; import { MembersListCard } from './MembersListCard'; describe('MemberTab Test', () => { @@ -78,7 +82,10 @@ describe('MemberTab Test', () => { const rendered = await renderWithEffects( wrapInTestApp( - + + + + , , ), ); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx index 579db9d9a5..7f8ede2eb7 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -20,7 +20,11 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Avatar, InfoCard, Progress, useApi } from '@backstage/core'; -import { catalogApiRef, entityRouteParams } from '@backstage/plugin-catalog'; +import { + useEntity, + catalogApiRef, + entityRouteParams, +} from '@backstage/plugin-catalog-react'; import { Box, createStyles, @@ -102,11 +106,11 @@ const MemberComponent = ({ ); }; -export const MembersListCard = ({ - entity: groupEntity, -}: { - entity: GroupEntity; +export const MembersListCard = (_props: { + /** @deprecated The entity is now grabbed from context instead */ + entity?: GroupEntity; }) => { + const groupEntity = useEntity().entity as GroupEntity; const { metadata: { name: groupName }, spec: { profile }, @@ -125,7 +129,9 @@ export const MembersListCard = ({ UserEntity >).filter(member => member?.relations?.some( - r => r.type === RELATION_MEMBER_OF && r.target.name === groupName, + r => + r.type === RELATION_MEMBER_OF && + r.target.name.toLowerCase() === groupName.toLowerCase(), ), ); return groupMembersList; diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index a61dd55161..80e80bddcc 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -14,12 +14,14 @@ * limitations under the License. */ -import React from 'react'; -import { InfoCard, useApi, Progress } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; -import { catalogApiRef } from '@backstage/plugin-catalog'; -import { useAsync } from 'react-use'; -import Alert from '@material-ui/lab/Alert'; +import { InfoCard, Progress, useApi } from '@backstage/core'; +import { + catalogApiRef, + isOwnerOf, + useEntity, +} from '@backstage/plugin-catalog-react'; +import { pageTheme } from '@backstage/theme'; import { Box, createStyles, @@ -28,8 +30,9 @@ import { Theme, Typography, } from '@material-ui/core'; -import { pageTheme } from '@backstage/theme'; -import { isOwnerOf } from '../../isOwnerOf'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { useAsync } from 'react-use'; type EntitiesKinds = 'Component' | 'API'; type EntitiesTypes = @@ -114,12 +117,13 @@ const EntityCountTile = ({ }; export const OwnershipCard = ({ - entity, variant, }: { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant: string; }) => { + const { entity } = useEntity(); const catalogApi = useApi(catalogApiRef); const { loading, diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx index 77708e6f27..7c7d26c228 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx @@ -15,6 +15,7 @@ */ import { UserEntity } from '@backstage/catalog-model'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; import React from 'react'; import { UserProfileCard } from './UserProfileCard'; @@ -48,7 +49,11 @@ describe('UserSummary Test', () => { it('Display Profile Card', async () => { const rendered = await renderWithEffects( - wrapInTestApp(), + wrapInTestApp( + + + , + ), ); expect(rendered.getByText('calum-leavy@example.com')).toBeInTheDocument(); diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index 99048c0cd8..1cf7c968b3 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -19,7 +19,7 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { entityRouteParams } from '@backstage/plugin-catalog'; +import { useEntity, entityRouteParams } from '@backstage/plugin-catalog-react'; import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; import EmailIcon from '@material-ui/icons/Email'; import GroupIcon from '@material-ui/icons/Group'; @@ -60,12 +60,13 @@ const CardTitle = ({ title }: { title?: string }) => ) : null; export const UserProfileCard = ({ - entity: user, variant, }: { - entity: UserEntity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: UserEntity; variant: string; }) => { + const user = useEntity().entity as UserEntity; const { metadata: { name: metaName }, spec: { profile }, diff --git a/plugins/org/src/components/getEntityRelations.ts b/plugins/org/src/components/getEntityRelations.ts deleted file mode 100644 index 9230aaebee..0000000000 --- a/plugins/org/src/components/getEntityRelations.ts +++ /dev/null @@ -1,42 +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 { Entity, EntityName } from '@backstage/catalog-model'; - -// TODO: this file is copied from /packages/app/catalog/src/components/getEntityRelations.ts and -// should be replaced once common relation-functions are introduced. - -/** - * Get the related entity references. - */ -export function getEntityRelations( - entity: Entity | undefined, - relationType: string, - filter?: { kind: string }, -): EntityName[] { - let entityNames = - entity?.relations - ?.filter(r => r.type === relationType) - ?.map(r => r.target) || []; - - if (filter?.kind) { - entityNames = entityNames?.filter( - e => e.kind.toLowerCase() === filter.kind.toLowerCase(), - ); - } - - return entityNames; -} diff --git a/plugins/org/src/components/isOwnerOf.ts b/plugins/org/src/components/isOwnerOf.ts deleted file mode 100644 index dd7c7d6805..0000000000 --- a/plugins/org/src/components/isOwnerOf.ts +++ /dev/null @@ -1,55 +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 { - Entity, - EntityName, - getEntityName, - RELATION_MEMBER_OF, - RELATION_OWNED_BY, -} from '@backstage/catalog-model'; -import { getEntityRelations } from './getEntityRelations'; - -// TODO: this file is copied from /packages/app/catalog/src/components/isOwnerOf.ts and -// should be replaced once common relation-functions are introduced. - -/** - * Check if one entity is owned by another. Returns true, if the entity is owned by the - * owner directly, or if the entity is owned by a group that the owner is a member of. - */ -export function isOwnerOf(owner: Entity, owned: Entity) { - const possibleOwners: EntityName[] = [ - ...getEntityRelations(owner, RELATION_MEMBER_OF, { kind: 'group' }), - ...(owner ? [getEntityName(owner)] : []), - ]; - - const owners = getEntityRelations(owned, RELATION_OWNED_BY); - - for (const owner of owners) { - if ( - possibleOwners.find( - o => - owner.kind.toLowerCase() === o.kind.toLowerCase() && - owner.namespace.toLowerCase() === o.namespace.toLowerCase() && - owner.name.toLowerCase() === o.name.toLowerCase(), - ) !== undefined - ) { - return true; - } - } - - return false; -} diff --git a/plugins/org/src/index.ts b/plugins/org/src/index.ts index 77ad7f9266..36c5f94ee5 100644 --- a/plugins/org/src/index.ts +++ b/plugins/org/src/index.ts @@ -13,5 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin } from './plugin'; +export { + orgPlugin, + orgPlugin as plugin, + EntityGroupProfileCard, + EntityMembersListCard, + EntityOwnershipCard, + EntityUserProfileCard, +} from './plugin'; export * from './components'; diff --git a/plugins/org/src/plugin.test.ts b/plugins/org/src/plugin.test.ts index d77cfd7ae8..e488422441 100644 --- a/plugins/org/src/plugin.test.ts +++ b/plugins/org/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { plugin } from './plugin'; +import { orgPlugin } from './plugin'; describe('groups', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(orgPlugin).toBeDefined(); }); }); diff --git a/plugins/org/src/plugin.ts b/plugins/org/src/plugin.ts index 39c3502fb5..195e88ea41 100644 --- a/plugins/org/src/plugin.ts +++ b/plugins/org/src/plugin.ts @@ -13,8 +13,37 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { createComponentExtension, createPlugin } from '@backstage/core'; -export const plugin = createPlugin({ +export const orgPlugin = createPlugin({ id: 'org', }); + +export const EntityGroupProfileCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.GroupProfileCard), + }, + }), +); +export const EntityMembersListCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.MembersListCard), + }, + }), +); +export const EntityOwnershipCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.OwnershipCard), + }, + }), +); +export const EntityUserProfileCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.UserProfileCard), + }, + }), +); diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md index 3e967101a7..eb00ce36be 100644 --- a/plugins/pagerduty/CHANGELOG.md +++ b/plugins/pagerduty/CHANGELOG.md @@ -1,5 +1,42 @@ # @backstage/plugin-pagerduty +## 0.2.7 + +### Patch Changes + +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + +## 0.2.5 + +### Patch Changes + +- b7a124883: Optimize empty state image size. + ## 0.2.4 ### Patch Changes diff --git a/plugins/pagerduty/dev/index.tsx b/plugins/pagerduty/dev/index.tsx index 264d6f801f..ad46c0ca9e 100644 --- a/plugins/pagerduty/dev/index.tsx +++ b/plugins/pagerduty/dev/index.tsx @@ -14,6 +14,6 @@ * limitations under the License. */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { pagerDutyPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(pagerDutyPlugin).render(); diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 5c8890034f..afd41dcc46 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-pagerduty", - "version": "0.2.4", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.0", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -44,8 +45,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx index faa480f6e2..6fd036330a 100644 --- a/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx +++ b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx @@ -32,11 +32,11 @@ export const EscalationPolicy = ({ policyId }: Props) => { const { value: users, loading, error } = useAsync(async () => { const oncalls = await api.getOnCallByPolicyId(policyId); - const users = oncalls + const usersItem = oncalls .sort((a, b) => a.escalation_level - b.escalation_level) .map(oncall => oncall.user); - return users; + return usersItem; }); if (error) { diff --git a/plugins/pagerduty/src/components/PagerDutyCard.test.tsx b/plugins/pagerduty/src/components/PagerDutyCard.test.tsx index a6615aee82..a719a27f86 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard.test.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard.test.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { render, waitFor, fireEvent, act } from '@testing-library/react'; import { PagerDutyCard } from './PagerDutyCard'; import { Entity } from '@backstage/catalog-model'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { wrapInTestApp } from '@backstage/test-utils'; import { alertApiRef, @@ -80,7 +81,9 @@ describe('PageDutyCard', () => { const { getByText, queryByTestId } = render( wrapInTestApp( - + + + , ), ); @@ -99,7 +102,9 @@ describe('PageDutyCard', () => { const { getByText, queryByTestId } = render( wrapInTestApp( - + + + , ), ); @@ -114,7 +119,9 @@ describe('PageDutyCard', () => { const { getByText, queryByTestId } = render( wrapInTestApp( - + + + , ), ); @@ -134,7 +141,9 @@ describe('PageDutyCard', () => { const { getByText, queryByTestId, getByTestId, getByRole } = render( wrapInTestApp( - + + + , ), ); diff --git a/plugins/pagerduty/src/components/PagerDutyCard.tsx b/plugins/pagerduty/src/components/PagerDutyCard.tsx index 5fb2cd7f4a..e03fb0fdbe 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard.tsx @@ -16,6 +16,7 @@ import React, { useState, useCallback } from 'react'; import { useApi, Progress, HeaderIconLinkRow } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Button, makeStyles, @@ -56,11 +57,13 @@ export const isPluginApplicableToEntity = (entity: Entity) => Boolean(entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY]); type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; }; -export const PagerDutyCard = ({ entity }: Props) => { +export const PagerDutyCard = (_props: Props) => { const classes = useStyles(); + const { entity } = useEntity(); const api = useApi(pagerDutyApiRef); const [showDialog, setShowDialog] = useState(false); const [refreshIncidents, setRefreshIncidents] = useState(false); diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx index 4fa88a1b9a..c390ed1f82 100644 --- a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx @@ -52,11 +52,11 @@ export const TriggerDialog = ({ const [description, setDescription] = useState(''); const [{ value, loading, error }, handleTriggerAlarm] = useAsyncFn( - async (description: string) => + async (descriptions: string) => await api.triggerAlarm({ integrationKey, source: window.location.toString(), - description, + description: descriptions, userName, }), ); diff --git a/plugins/pagerduty/src/index.ts b/plugins/pagerduty/src/index.ts index 4ecd4edcc6..2dfedab164 100644 --- a/plugins/pagerduty/src/index.ts +++ b/plugins/pagerduty/src/index.ts @@ -13,9 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin } from './plugin'; +export { + pagerDutyPlugin, + pagerDutyPlugin as plugin, + EntityPagerDutyCard, +} from './plugin'; export { isPluginApplicableToEntity, + isPluginApplicableToEntity as isPagerDutyAvailable, PagerDutyCard, } from './components/PagerDutyCard'; export { diff --git a/plugins/pagerduty/src/plugin.test.ts b/plugins/pagerduty/src/plugin.test.ts index 8d4545ac12..c1175ab384 100644 --- a/plugins/pagerduty/src/plugin.test.ts +++ b/plugins/pagerduty/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { plugin } from './plugin'; +import { pagerDutyPlugin } from './plugin'; describe('pagerduty', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(pagerDutyPlugin).toBeDefined(); }); }); diff --git a/plugins/pagerduty/src/plugin.ts b/plugins/pagerduty/src/plugin.ts index 34796f491e..fbe827b90d 100644 --- a/plugins/pagerduty/src/plugin.ts +++ b/plugins/pagerduty/src/plugin.ts @@ -19,6 +19,7 @@ import { createRouteRef, discoveryApiRef, configApiRef, + createComponentExtension, } from '@backstage/core'; import { pagerDutyApiRef, PagerDutyClient } from './api'; @@ -27,7 +28,7 @@ export const rootRouteRef = createRouteRef({ title: 'pagerduty', }); -export const plugin = createPlugin({ +export const pagerDutyPlugin = createPlugin({ id: 'pagerduty', apis: [ createApiFactory({ @@ -38,3 +39,12 @@ export const plugin = createPlugin({ }), ], }); + +export const EntityPagerDutyCard = pagerDutyPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/PagerDutyCard').then(m => m.PagerDutyCard), + }, + }), +); diff --git a/plugins/proxy-backend/CHANGELOG.md b/plugins/proxy-backend/CHANGELOG.md index 0c928d19f9..0d9338fcc4 100644 --- a/plugins/proxy-backend/CHANGELOG.md +++ b/plugins/proxy-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-proxy-backend +## 0.2.4 + +### Patch Changes + +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] + - @backstage/backend-common@0.5.0 + ## 0.2.3 ### Patch Changes diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index a39d839fd1..51ccf98f8b 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-proxy-backend", - "version": "0.2.3", + "version": "0.2.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,7 +28,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.0", + "@backstage/backend-common": "^0.5.2", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -42,7 +42,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.1", + "@backstage/cli": "^0.6.0", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/register-component/CHANGELOG.md b/plugins/register-component/CHANGELOG.md index 949ac77b56..901c556052 100644 --- a/plugins/register-component/CHANGELOG.md +++ b/plugins/register-component/CHANGELOG.md @@ -1,5 +1,43 @@ # @backstage/plugin-register-component +## 0.2.8 + +### Patch Changes + +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + ## 0.2.6 ### Patch Changes diff --git a/plugins/register-component/dev/index.tsx b/plugins/register-component/dev/index.tsx index 812a5585d4..3304d0874f 100644 --- a/plugins/register-component/dev/index.tsx +++ b/plugins/register-component/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { registerComponentPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(registerComponentPlugin).render(); diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 4f161a7398..9e0f045cd4 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "version": "0.2.6", + "version": "0.2.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.9", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -45,8 +45,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index 6b097085e9..e9da8ef84d 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -20,7 +20,7 @@ import { createRouteRef, errorApiRef, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import { render, screen } from '@testing-library/react'; diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index d0b56598b2..8dbf712559 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -14,23 +14,23 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { Grid, makeStyles } from '@material-ui/core'; +import { Entity, Location } from '@backstage/catalog-model'; import { - InfoCard, - Page, Content, - useApi, + ContentHeader, errorApiRef, Header, - SupportButton, - ContentHeader, + InfoCard, + Page, RouteRef, + SupportButton, + useApi, } from '@backstage/core'; -import { RegisterComponentForm } from '../RegisterComponentForm'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Grid, makeStyles } from '@material-ui/core'; +import React, { useState } from 'react'; import { useMountedState } from 'react-use'; -import { Entity, Location } from '@backstage/catalog-model'; +import { RegisterComponentForm } from '../RegisterComponentForm'; import { RegisterComponentResultDialog } from '../RegisterComponentResultDialog'; const useStyles = makeStyles(theme => ({ diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index c31d1344cb..87248cb6db 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -16,7 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { RouteRef, StructuredMetadataTable } from '@backstage/core'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; +import { + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog-react'; import { Button, Dialog, diff --git a/plugins/register-component/src/index.ts b/plugins/register-component/src/index.ts index ff7857cacd..56bcd06fde 100644 --- a/plugins/register-component/src/index.ts +++ b/plugins/register-component/src/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + registerComponentPlugin, + registerComponentPlugin as plugin, + RegisterComponentPage, +} from './plugin'; export { Router } from './components/Router'; diff --git a/plugins/register-component/src/plugin.test.ts b/plugins/register-component/src/plugin.test.ts index 1e61060202..2b6ad48bcf 100644 --- a/plugins/register-component/src/plugin.test.ts +++ b/plugins/register-component/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { registerComponentPlugin } from './plugin'; describe('register-component', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(registerComponentPlugin).toBeDefined(); }); }); diff --git a/plugins/register-component/src/plugin.ts b/plugins/register-component/src/plugin.ts index 17f99917c8..d3727158e5 100644 --- a/plugins/register-component/src/plugin.ts +++ b/plugins/register-component/src/plugin.ts @@ -14,8 +14,29 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { + createPlugin, + createRoutableExtension, + createRouteRef, +} from '@backstage/core'; -export const plugin = createPlugin({ - id: 'register-component', +const rootRouteRef = createRouteRef({ + title: 'Register Component', }); + +export const registerComponentPlugin = createPlugin({ + id: 'register-component', + routes: { + root: rootRouteRef, + }, +}); + +export const RegisterComponentPage = registerComponentPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/RegisterComponentPage').then( + m => m.RegisterComponentPage, + ), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/rollbar-backend/CHANGELOG.md b/plugins/rollbar-backend/CHANGELOG.md index 4093e5f3af..08b4b9da77 100644 --- a/plugins/rollbar-backend/CHANGELOG.md +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-rollbar-backend +## 0.1.7 + +### Patch Changes + +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [09a370426] + - @backstage/backend-common@0.5.0 + +## 0.1.6 + +### Patch Changes + +- dde4ab398: Bump `axios` from `^0.20.0` to `^0.21.1`. +- Updated dependencies [5ecd50f8a] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/backend-common@0.4.2 + ## 0.1.5 ### Patch Changes diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 2c88692964..ff7a4f06f5 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar-backend", - "version": "0.1.5", + "version": "0.1.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.0", + "@backstage/backend-common": "^0.5.2", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", - "axios": "^0.20.0", + "axios": "^0.21.1", "camelcase-keys": "^6.2.2", "compression": "^1.7.4", "cors": "^2.8.5", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.1", + "@backstage/cli": "^0.6.0", "@types/supertest": "^2.0.8", "supertest": "^4.0.2" }, diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index aaa7e704d9..e49c843116 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,44 @@ # @backstage/plugin-rollbar +## 0.2.9 + +### Patch Changes + +- 9d6ef14bc: Migrated to new composability API, exporting the plugin instance as `rollbarPlugin`, the entity page content as `EntityRollbarContent`, and entity conditional as `isRollbarAvailable`. Updated the `EntityPage` for the `example-app` to include a composite `ErrorsSwitcher` component that works with both `Sentry` & `Rollbar`. Also removed the unused and undocumented `RollbarHome` related components. +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.2.8 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + ## 0.2.7 ### Patch Changes diff --git a/plugins/rollbar/README.md b/plugins/rollbar/README.md index cc862051f7..6337d45ffe 100644 --- a/plugins/rollbar/README.md +++ b/plugins/rollbar/README.md @@ -19,17 +19,7 @@ yarn add @backstage/plugin-rollbar export { plugin as Rollbar } from '@backstage/plugin-rollbar'; ``` -4. Add plugin API to your Backstage instance: - -```ts -// packages/app/src/api.ts -import { RollbarClient, rollbarApiRef } from '@backstage/plugin-rollbar'; - -// ... -builder.add(rollbarApiRef, new RollbarClient({ discoveryApi })); -``` - -5. Add to the app `EntityPage` component: +4. Add to the app `EntityPage` component: ```ts // packages/app/src/components/catalog/EntityPage.tsx @@ -48,7 +38,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( ); ``` -6. Setup the `app.config.yaml` and account token environment variable +5. Setup the `app.config.yaml` and account token environment variable ```yaml # app.config.yaml @@ -59,7 +49,7 @@ rollbar: $env: ROLLBAR_ACCOUNT_TOKEN ``` -7. Annotate entities with the rollbar project slug +6. Annotate entities with the rollbar project slug ```yaml # pump-station-catalog-component.yaml @@ -71,7 +61,7 @@ metadata: rollbar.com/project-slug: project-name ``` -8. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity +7. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity ## Features diff --git a/plugins/rollbar/dev/index.tsx b/plugins/rollbar/dev/index.tsx index 812a5585d4..6cac0d9a0c 100644 --- a/plugins/rollbar/dev/index.tsx +++ b/plugins/rollbar/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { rollbarPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(rollbarPlugin).render(); diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 90a2d1b218..44d8583bfa 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.2.7", + "version": "0.2.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -47,8 +47,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx b/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx index 888b9aabe0..07a39bd147 100644 --- a/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx +++ b/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx @@ -14,14 +14,18 @@ * limitations under the License. */ -import React from 'react'; import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; import { RollbarProject } from '../RollbarProject/RollbarProject'; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; }; -export const EntityPageRollbar = ({ entity }: Props) => { +export const EntityPageRollbar = (_props: Props) => { + const { entity } = useEntity(); + return ; }; diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx deleted file mode 100644 index ff3e909bf7..0000000000 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx +++ /dev/null @@ -1,70 +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 * as React from 'react'; -import { - ApiProvider, - ApiRegistry, - ConfigApi, - configApiRef, -} from '@backstage/core'; -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { render } from '@testing-library/react'; -import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi'; -import { RollbarProject } from '../../api/types'; -import { RollbarHome } from './RollbarHome'; - -describe('RollbarHome component', () => { - const projects: RollbarProject[] = [ - { id: 123, name: 'abc', accountId: 1, status: 'enabled' }, - { id: 456, name: 'xyz', accountId: 1, status: 'enabled' }, - ]; - const rollbarApi: Partial = { - getAllProjects: () => Promise.resolve(projects), - }; - const config: Partial = { - getString: () => 'foo', - getOptionalString: () => 'foo', - }; - - const renderWrapped = (children: React.ReactNode) => - render( - wrapInTestApp( - ) as CatalogApi, - ], - ])} - > - {children} - , - ), - ); - - it('should render rollbar landing page', async () => { - const rendered = renderWrapped(); - expect(rendered.getByText(/Rollbar/)).toBeInTheDocument(); - }); -}); diff --git a/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx b/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx index 34881ba560..b69c69a56b 100644 --- a/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx +++ b/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import React from 'react'; import { Entity } from '@backstage/catalog-model'; +import React from 'react'; import { useTopActiveItems } from '../../hooks/useTopActiveItems'; import { RollbarTopItemsTable } from '../RollbarTopItemsTable/RollbarTopItemsTable'; diff --git a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx deleted file mode 100644 index a1c54dfd5a..0000000000 --- a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx +++ /dev/null @@ -1,86 +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 * as React from 'react'; -import { - ApiProvider, - ApiRegistry, - ConfigApi, - configApiRef, -} from '@backstage/core'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { render } from '@testing-library/react'; -import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi'; -import { RollbarTopActiveItem } from '../../api/types'; -import { RollbarProjectPage } from './RollbarProjectPage'; -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; - -describe('RollbarProjectPage component', () => { - const items: RollbarTopActiveItem[] = [ - { - item: { - id: 9898989, - counter: 1234, - environment: 'production', - framework: 2, - lastOccurrenceTimestamp: new Date().getTime() / 1000, - level: 50, - occurrences: 100, - projectId: 12345, - title: 'error occurred', - uniqueOccurrences: 10, - }, - counts: [10, 10, 10, 10, 10, 50], - }, - ]; - const rollbarApi: Partial = { - getTopActiveItems: () => Promise.resolve(items), - }; - const config: Partial = { - getString: () => 'foo', - getOptionalString: () => 'foo', - }; - - const renderWrapped = (children: React.ReactNode) => - render( - wrapInTestApp( - ) as CatalogApi, - ], - ])} - > - {children} - , - ), - ); - - it('should render rollbar project page', async () => { - const rendered = renderWrapped(); - expect(rendered.getByText(/Rollbar/)).toBeInTheDocument(); - }); -}); diff --git a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx deleted file mode 100644 index 5563a6d7a0..0000000000 --- a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Content, Header, HeaderLabel, Page } from '@backstage/core'; -import { useCatalogEntity } from '../../hooks/useCatalogEntity'; -import { RollbarProject } from '../RollbarProject/RollbarProject'; - -export const RollbarProjectPage = () => { - const { entity } = useCatalogEntity(); - - return ( - -
- - -
- - {entity ? : 'Loading'} - -
- ); -}; diff --git a/plugins/rollbar/src/components/RollbarProjectTable/RollbarProjectTable.tsx b/plugins/rollbar/src/components/RollbarProjectTable/RollbarProjectTable.tsx deleted file mode 100644 index 5b3631ade9..0000000000 --- a/plugins/rollbar/src/components/RollbarProjectTable/RollbarProjectTable.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Link as RouterLink, generatePath } from 'react-router-dom'; -import { Table, TableColumn } from '@backstage/core'; -import { Entity } from '@backstage/catalog-model'; -import { Link } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import { entityRouteRef } from '../../routes'; - -const columns: TableColumn[] = [ - { - title: 'Name', - field: 'metadata.name', - type: 'string', - highlight: true, - render: (entity: any) => ( - - {entity.metadata.name} - - ), - }, - { - title: 'Description', - field: 'metadata.description', - }, -]; - -type Props = { - entities: Entity[]; - loading: boolean; - error?: any; -}; - -export const RollbarProjectTable = ({ entities, loading, error }: Props) => { - if (error) { - return ( -
- - Error encountered while fetching rollbar projects. {error.toString()} - -
- ); - } - - return ( -
- ); -}; diff --git a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx index dc177265cd..2916fefbcf 100644 --- a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx +++ b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx @@ -17,8 +17,8 @@ import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import * as React from 'react'; -import { RollbarTopItemsTable } from './RollbarTopItemsTable'; import { RollbarTopActiveItem } from '../../api/types'; +import { RollbarTopItemsTable } from './RollbarTopItemsTable'; const items: RollbarTopActiveItem[] = [ { diff --git a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx index a04155a0c3..c22f813dd9 100644 --- a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx +++ b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React from 'react'; import { Table, TableColumn } from '@backstage/core'; import { Box, Link, Typography } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; +import React from 'react'; import { RollbarFrameworkId, RollbarLevel, diff --git a/plugins/rollbar/src/components/Router.tsx b/plugins/rollbar/src/components/Router.tsx index f0756cb02a..7b95a4ec7c 100644 --- a/plugins/rollbar/src/components/Router.tsx +++ b/plugins/rollbar/src/components/Router.tsx @@ -14,29 +14,36 @@ * limitations under the License. */ -import React from 'react'; -import { Routes, Route } from 'react-router'; import { Entity } from '@backstage/catalog-model'; import { MissingAnnotationEmptyState } from '@backstage/core'; -import { catalogRouteRef } from '../routes'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { Route, Routes } from 'react-router'; import { ROLLBAR_ANNOTATION } from '../constants'; +import { rootRouteRef } from '../plugin'; import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar'; export const isPluginApplicableToEntity = (entity: Entity) => Boolean(entity.metadata.annotations?.[ROLLBAR_ANNOTATION]); type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; }; -export const Router = ({ entity }: Props) => - !isPluginApplicableToEntity(entity) ? ( - - ) : ( +export const Router = (_props: Props) => { + const { entity } = useEntity(); + + if (!isPluginApplicableToEntity(entity)) { + ; + } + + return ( } /> ); +}; diff --git a/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx b/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx index ac1a328521..e944a53551 100644 --- a/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx +++ b/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import React from 'react'; import { TrendGraph } from './TrendGraph'; describe('TrendGraph component', () => { diff --git a/plugins/rollbar/src/hooks/useCatalogEntity.ts b/plugins/rollbar/src/hooks/useCatalogEntity.ts index 172d057738..e39a7a75f7 100644 --- a/plugins/rollbar/src/hooks/useCatalogEntity.ts +++ b/plugins/rollbar/src/hooks/useCatalogEntity.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { useAsync } from 'react-use'; import { useApi } from '@backstage/core'; import { catalogApiRef, useEntityCompoundName, -} from '@backstage/plugin-catalog'; +} from '@backstage/plugin-catalog-react'; +import { useAsync } from 'react-use'; export function useCatalogEntity() { const catalogApi = useApi(catalogApiRef); diff --git a/plugins/rollbar/src/hooks/useRollbarEntities.ts b/plugins/rollbar/src/hooks/useRollbarEntities.ts index 5979c5832a..3f3415b176 100644 --- a/plugins/rollbar/src/hooks/useRollbarEntities.ts +++ b/plugins/rollbar/src/hooks/useRollbarEntities.ts @@ -14,9 +14,9 @@ * limitations under the License. */ +import { configApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; -import { useApi, configApiRef } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; import { ROLLBAR_ANNOTATION } from '../constants'; export function useRollbarEntities() { diff --git a/plugins/rollbar/src/index.ts b/plugins/rollbar/src/index.ts index 2a31c91a13..def2d99658 100644 --- a/plugins/rollbar/src/index.ts +++ b/plugins/rollbar/src/index.ts @@ -14,10 +14,16 @@ * limitations under the License. */ -export { plugin } from './plugin'; export * from './api'; -export * from './routes'; -export { Router } from './components/Router'; -export { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage'; export { EntityPageRollbar } from './components/EntityPageRollbar/EntityPageRollbar'; +export { + isPluginApplicableToEntity, + isPluginApplicableToEntity as isRollbarAvailable, + Router, +} from './components/Router'; export { ROLLBAR_ANNOTATION } from './constants'; +export { + EntityRollbarContent, + rollbarPlugin as plugin, + rollbarPlugin, +} from './plugin'; diff --git a/plugins/rollbar/src/plugin.test.ts b/plugins/rollbar/src/plugin.test.ts index 44c2168c78..7ef23b019a 100644 --- a/plugins/rollbar/src/plugin.test.ts +++ b/plugins/rollbar/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { rollbarPlugin } from './plugin'; describe('rollbar', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(rollbarPlugin).toBeDefined(); }); }); diff --git a/plugins/rollbar/src/plugin.ts b/plugins/rollbar/src/plugin.ts index c3ca6173ff..216713b3fc 100644 --- a/plugins/rollbar/src/plugin.ts +++ b/plugins/rollbar/src/plugin.ts @@ -15,17 +15,21 @@ */ import { - createPlugin, createApiFactory, + createPlugin, + createRoutableExtension, + createRouteRef, discoveryApiRef, } from '@backstage/core'; -import { rootRouteRef, entityRouteRef } from './routes'; -import { RollbarHome } from './components/RollbarHome/RollbarHome'; -import { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage'; import { rollbarApiRef } from './api/RollbarApi'; import { RollbarClient } from './api/RollbarClient'; -export const plugin = createPlugin({ +export const rootRouteRef = createRouteRef({ + path: '', + title: 'Rollbar', +}); + +export const rollbarPlugin = createPlugin({ id: 'rollbar', apis: [ createApiFactory({ @@ -34,8 +38,14 @@ export const plugin = createPlugin({ factory: ({ discoveryApi }) => new RollbarClient({ discoveryApi }), }), ], - register({ router }) { - router.addRoute(rootRouteRef, RollbarHome); - router.addRoute(entityRouteRef, RollbarProjectPage); + routes: { + entityContent: rootRouteRef, }, }); + +export const EntityRollbarContent = rollbarPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index e63c7e23fe..e8526e0b56 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,10 +1,252 @@ # @backstage/plugin-scaffolder-backend +## 0.6.0 + +### Minor Changes + +- cdea0baf1: The scaffolder is updated to generate a unique workspace directory inside the temp folder. This directory is cleaned up by the job processor after each run. + + The prepare/template/publish steps have been refactored to operate on known directories, `template/` and `result/`, inside the temporary workspace path. + + Updated preparers to accept the template url instead of the entire template. This is done primarily to allow for backwards compatibility between v1 and v2 scaffolder templates. + + Fixes broken GitHub actions templating in the Create React App template. + + #### For those with **custom** preparers, templates, or publishers + + The preparer interface has changed, the prepare method now only takes a single argument, and doesn't return anything. As part of this change the preparers were refactored to accept a URL pointing to the target directory, rather than computing that from the template entity. + + The `workingDirectory` option was also removed, and replaced with a `workspacePath` option. The difference between the two is that `workingDirectory` was a place for the preparer to create temporary directories, while the `workspacePath` is the specific folder were the entire templating process for a single template job takes place. Instead of returning a path to the folder were the prepared contents were placed, the contents are put at the `/template` path. + + ```diff + type PreparerOptions = { + - workingDirectory?: string; + + /** + + * Full URL to the directory containg template data + + */ + + url: string; + + /** + + * The workspace path that will eventually be the the root of the new repo + + */ + + workspacePath: string; + logger: Logger; + }; + + -prepare(template: TemplateEntityV1alpha1, opts?: PreparerOptions): Promise + +prepare(opts: PreparerOptions): Promise; + ``` + + Instead of returning a path to the folder were the templaters contents were placed, the contents are put at the `/result` path. All templaters now also expect the source template to be present in the `template` directory within the `workspacePath`. + + ```diff + export type TemplaterRunOptions = { + - directory: string; + + workspacePath: string; + values: TemplaterValues; + logStream?: Writable; + dockerClient: Docker; + }; + + -public async run(options: TemplaterRunOptions): Promise + +public async run(options: TemplaterRunOptions): Promise + ``` + + Just like the preparer and templaters, the publishers have also switched to using `workspacePath`. The root of the new repo is expected to be located at `/result`. + + ```diff + export type PublisherOptions = { + values: TemplaterValues; + - directory: string; + + workspacePath: string; + logger: Logger; + }; + ``` + +### Patch Changes + +- a26668913: Attempt to fix windows test errors in master +- 529d16d27: # Repo visibility for GitLab and BitBucket repos + + **NOTE: This changes default repo visibility from `private` to `public` for GitLab and BitBucket** which + is consistent with the GitHub default. If you were counting on `private` visibility, you'll need to update + your scaffolder config to use `private`. + + This adds repo visibility feature parity with GitHub for GitLab and BitBucket. + + To configure the repo visibility, set scaffolder._type_.visibility as in this example: + + ```yaml + scaffolder: + github: + visibility: private # 'public' or 'internal' or 'private' (default is 'public') + gitlab: + visibility: public # 'public' or 'internal' or 'private' (default is 'public') + bitbucket: + visibility: public # 'public' or 'private' (default is 'public') + ``` + +- Updated dependencies [c4abcdb60] +- Updated dependencies [2430ee7c2] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [064c513e1] +- Updated dependencies [7881f2117] +- Updated dependencies [3149bfe63] +- Updated dependencies [2e62aea6f] +- Updated dependencies [11cb5ef94] + - @backstage/integration@0.3.2 + - @backstage/backend-common@0.5.2 + - @backstage/catalog-model@0.7.1 + +## 0.5.2 + +### Patch Changes + +- 26a3a6cf0: Honor the branch ref in the url when cloning. + + This fixes a bug in the scaffolder prepare stage where a non-default branch + was specified in the scaffolder URL but the default branch was cloned. + For example, even though the `other` branch is specified in this example, the + `master` branch was actually cloned: + + ```yaml + catalog: + locations: + - type: url + target: https://github.com/backstage/backstage/blob/other/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml + ``` + + This also fixes a 404 in the prepare stage for GitLab URLs. + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + - @backstage/integration@0.3.1 + +## 0.5.1 + +### Patch Changes + +- 0ea002378: Fixing issues with templating and full URL's as `storePath`'s + +## 0.5.0 + +### Minor Changes + +- ed6baab66: - Deprecating the `scaffolder.${provider}.token` auth duplication and favoring `integrations.${provider}` instead. If you receive deprecation warnings your config should change like the following: + + ```yaml + scaffolder: + github: + token: + $env: GITHUB_TOKEN + visibility: public + ``` + + To something that looks like this: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + scaffolder: + github: + visibility: public + ``` + + You can also configure multiple different hosts under the `integration` config like the following: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + - host: ghe.mycompany.com + token: + $env: GITHUB_ENTERPRISE_TOKEN + ``` + + This of course is the case for all the providers respectively. + + - Adding support for cross provider scaffolding, you can now create repositories in for example Bitbucket using a template residing in GitHub. + + - Fix GitLab scaffolding so that it returns a `catalogInfoUrl` which automatically imports the project into the catalog. + + - The `Store Path` field on the `scaffolder` frontend has now changed so that you require the full URL to the desired destination repository. + + `backstage/new-repository` would become `https://github.com/backstage/new-repository` if provider was GitHub for example. + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [fa8ba330a] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [ed6baab66] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + - @backstage/integration@0.3.0 + +## 0.4.1 + +### Patch Changes + +- 94fdf4955: Get rid of all usages of @octokit/types, and bump the rest of the octokit dependencies to the latest version +- cc068c0d6: Bump the gitbeaker dependencies to 28.x. + + To update your own installation, go through the `package.json` files of all of + your packages, and ensure that all dependencies on `@gitbeaker/node` or + `@gitbeaker/core` are at version `^28.0.2`. Then run `yarn install` at the root + of your repo. + +- 711ba55a2: Export all preparers and publishers properly +- Updated dependencies [466354aaa] +- Updated dependencies [f3b064e1c] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/integration@0.2.0 + - @backstage/catalog-model@0.6.1 + - @backstage/backend-common@0.4.3 + +## 0.4.0 + +### Minor Changes + +- 5eb8c9b9e: Fix GitLab scaffolder publisher + +### Patch Changes + +- 7e3451700: bug(scaffolder): Ignore the .git folder when adding dot-files to the index + +## 0.3.7 + +### Patch Changes + +- 37a5244ef: Add scaffolding support for Bitbucket Cloud and Server. +- 00042e73c: Moving the Git actions to isomorphic-git instead of the node binding version of nodegit +- 9efbc5585: Add config schema for Bitbucket scaffolder +- Updated dependencies [5ecd50f8a] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/backend-common@0.4.2 + - @backstage/integration@0.1.5 + ## 0.3.6 ### Patch Changes -- 19554f6d6: Added Github Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` +- 19554f6d6: Added GitHub Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` - 33a82a713: GitLab preparer uses the right token (primarily the same one as the publisher, falling back to the integrations token) - aed8f7f12: Clearer error message when preparer or publisher type can't be determined. diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 28e2515471..3b31b4f951 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -19,9 +19,17 @@ export interface Config { scaffolder?: { github?: { [key: string]: string; + /** + * The visibility to set on created repositories. + */ + visiblity?: 'public' | 'internal' | 'private'; }; gitlab?: { api: { [key: string]: string }; + /** + * The visibility to set on created repositories. + */ + visiblity?: 'public' | 'internal' | 'private'; }; azure?: { baseUrl: string; @@ -29,6 +37,10 @@ export interface Config { }; bitbucket?: { api: { [key: string]: string }; + /** + * The visibility to set on created repositories. + */ + visiblity?: 'public' | 'private'; }; }; } diff --git a/plugins/scaffolder-backend/migrations/20210120143715_init.js b/plugins/scaffolder-backend/migrations/20210120143715_init.js new file mode 100644 index 0000000000..256ec7d423 --- /dev/null +++ b/plugins/scaffolder-backend/migrations/20210120143715_init.js @@ -0,0 +1,85 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.createTable('tasks', table => { + table.comment('The table of scaffolder tasks'); + table.uuid('id').primary().notNullable().comment('The ID of the task'); + table + .text('spec') + .notNullable() + .comment('A JSON encoded task specification'); + table + .text('status') + .notNullable() + .comment('The current status of the task'); + table + .dateTime('created_at') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('The timestamp when this task was created'); + table + .dateTime('last_heartbeat_at') + .nullable() + .comment('The last timestamp when a heartbeat was received'); + }); + + await knex.schema.createTable('task_events', table => { + table.comment('The event stream a given task'); + table + .bigIncrements('id') + .primary() + .notNullable() + .comment('The ID of the event'); + table + .uuid('task_id') + .references('id') + .inTable('tasks') + .notNullable() + .onDelete('CASCADE') + .comment('The task that generated the event'); + table + .text('body') + .notNullable() + .comment('The JSON encoded body of the event'); + table.text('event_type').notNullable().comment('The type of event'); + table + .timestamp('created_at') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('The timestamp when this event was generated'); + + table.index(['task_id'], 'task_events_task_id_idx'); + }); +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + if (knex.client.config.client !== 'sqlite3') { + await knex.schema.alterTable('task_events', table => { + table.dropIndex([], 'ctask_events_task_id_idx'); + }); + } + await knex.schema.dropTable('task_events'); + await knex.schema.dropTable('tasks'); +}; diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index c290a94b19..ecce161c8e 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.3.6", + "version": "0.6.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,15 +29,16 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.1.4", - "@gitbeaker/core": "^25.2.0", - "@gitbeaker/node": "^25.2.0", - "@octokit/rest": "^18.0.0", + "@backstage/integration": "^0.3.2", + "@gitbeaker/core": "^28.0.2", + "@gitbeaker/node": "^28.0.2", + "@octokit/rest": "^18.0.12", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", + "@types/git-url-parse": "^9.0.0", "azure-devops-node-api": "^10.1.1", "command-exists-promise": "^2.0.2", "compression": "^1.7.4", @@ -47,29 +48,31 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "globby": "^11.0.0", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", "jsonschema": "^1.2.6", + "knex": "^0.21.6", "morgan": "^1.10.0", "uuid": "^8.2.0", "winston": "^3.2.1", "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.3", + "@backstage/cli": "^0.6.0", "@backstage/test-utils": "^0.1.5", - "@octokit/types": "^5.4.1", "@types/fs-extra": "^9.0.1", - "@types/git-url-parse": "^9.0.0", + "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", + "mock-fs": "^4.13.0", "supertest": "^4.0.2", "yaml": "^1.10.0", "msw": "^0.21.2" }, "files": [ "dist", + "migrations", "config.d.ts" ], "configSchema": "config.d.ts" diff --git a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/catalog-info.yaml index e39350e928..2f9ce4d153 100644 --- a/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/catalog-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/docs-template/{{cookiecutter.component_id}}/catalog-info.yaml @@ -4,8 +4,8 @@ metadata: name: {{cookiecutter.component_id | jsonify}} description: {{cookiecutter.description | jsonify}} annotations: - github.com/project-slug: {{cookiecutter.storePath | jsonify}} - backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} + github.com/project-slug: {{cookiecutter.destination.git.owner + "/" + cookiecutter.destination.git.name}} + backstage.io/techdocs-ref: url:{{cookiecutter.storePath}} spec: type: documentation lifecycle: experimental diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/catalog-info.yaml index 423c8683a1..6197888f36 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/catalog-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/catalog-info.yaml @@ -4,8 +4,8 @@ metadata: name: {{cookiecutter.component_id | jsonify}} description: {{cookiecutter.description | jsonify}} annotations: - github.com/project-slug: {{cookiecutter.storePath | jsonify}} - backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} + github.com/project-slug: {{cookiecutter.destination.git.owner + "/" + cookiecutter.destination.git.name}} + backstage.io/techdocs-ref: url:{{cookiecutter.storePath}} spec: type: website lifecycle: experimental diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/catalog-info.yaml index f4ff350d10..02a8a7adf2 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/catalog-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/catalog-info.yaml @@ -4,8 +4,8 @@ metadata: name: {{cookiecutter.component_id | jsonify}} description: {{cookiecutter.description | jsonify}} annotations: - github.com/project-slug: {{cookiecutter.storePath | jsonify}} - backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} + github.com/project-slug: {{cookiecutter.destination.git.owner + "/" + cookiecutter.destination.git.name}} + backstage.io/techdocs-ref: url:{{cookiecutter.storePath}} spec: type: service lifecycle: experimental diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index d23db937bb..c782037ec8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -13,10 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { JobProcessor } from './processor'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { StageInput } from './types'; +import parseGitUrl from 'git-url-parse'; +import mockFs from 'mock-fs'; +import os from 'os'; import { RequiredTemplateValues } from '../stages/templater'; +import { makeLogStream } from './logger'; +import { JobProcessor } from './processor'; +import { StageInput } from './types'; describe('JobProcessor', () => { const mockEntity: TemplateEntityV1alpha1 = { @@ -30,7 +34,7 @@ describe('JobProcessor', () => { name: 'graphql-starter', title: 'GraphQL Service', description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', + 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', @@ -61,12 +65,36 @@ describe('JobProcessor', () => { const mockValues: RequiredTemplateValues = { owner: 'blobby', - storePath: 'backstage/mock-repo', + storePath: 'https://github.com/backstage/mock-repo', + destination: { + git: parseGitUrl('https://github.com/backstage/mock-repo'), + }, }; + const workingDirectory = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + + // NOTE(freben): Without this line, mock-fs makes winston/logform break. + // There are a number of reported issues with logform and its use of dynamic + // strings for imports. It confuses webpack. The basic fix is to trigger + // those imports before mock-fs runs. I wanted to add a mock dir + // 'node_modules': mockFs.passthrough(), but that doesn't seem to be a thing + // in mock-fs 4. + // Probable REAL fix: https://github.com/winstonjs/logform/pull/117 + makeLogStream({}); + + beforeEach(() => { + mockFs({ + [workingDirectory]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + describe('create', () => { it('creates should create a new job with a unique id', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -80,7 +108,7 @@ describe('JobProcessor', () => { }); it('should setup the correct context for the job', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -93,7 +121,7 @@ describe('JobProcessor', () => { }); it('should set the status as pending', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -116,7 +144,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -135,12 +163,12 @@ describe('JobProcessor', () => { describe('get', () => { it('return undefined for when the job does not exist', () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); expect(processor.get('123')).not.toBeDefined(); }); it('should return the exact same instance of the job when one is created', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -150,9 +178,10 @@ describe('JobProcessor', () => { expect(processor.get(job.id)).toBe(job); }); }); + describe('process', () => { it('throws an error when the status of the job is not in pending state', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -178,7 +207,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -204,7 +233,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -240,7 +269,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -279,7 +308,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index 25f9b23e59..425fc07946 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -13,14 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import os from 'os'; +import fs from 'fs-extra'; import { Processor, Job, StageContext, StageInput } from './types'; -import { JsonValue } from '@backstage/config'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import * as uuid from 'uuid'; import Docker from 'dockerode'; -import { RequiredTemplateValues, TemplaterBase } from '../stages/templater'; +import path from 'path'; +import { TemplaterValues, TemplaterBase } from '../stages/templater'; import { PreparerBuilder } from '../stages/prepare'; import { makeLogStream } from './logger'; +import { Logger } from 'winston'; +import { Config } from '@backstage/config'; export type JobProcessorArguments = { preparers: PreparerBuilder; @@ -34,7 +39,45 @@ export type JobAndDirectoryTuple = { }; export class JobProcessor implements Processor { - private jobs = new Map(); + private readonly workingDirectory: string; + private readonly jobs: Map; + + static async fromConfig({ + config, + logger, + }: { + config: Config; + logger: Logger; + }) { + let workingDirectory: string; + if (config.has('backend.workingDirectory')) { + workingDirectory = config.getString('backend.workingDirectory'); + try { + // Check if working directory exists and is writable + await fs.promises.access( + workingDirectory, + fs.constants.F_OK | fs.constants.W_OK, + ); + logger.info(`using working directory: ${workingDirectory}`); + } catch (err) { + logger.error( + `working directory ${workingDirectory} ${ + err.code === 'ENOENT' ? 'does not exist' : 'is not writable' + }`, + ); + throw err; + } + } else { + workingDirectory = os.tmpdir(); + } + + return new JobProcessor(workingDirectory); + } + + constructor(workingDirectory: string) { + this.workingDirectory = workingDirectory; + this.jobs = new Map(); + } create({ entity, @@ -42,7 +85,7 @@ export class JobProcessor implements Processor { stages, }: { entity: TemplateEntityV1alpha1; - values: RequiredTemplateValues & Record; + values: TemplaterValues; stages: StageInput[]; }): Job { const id = uuid.v4(); @@ -53,6 +96,7 @@ export class JobProcessor implements Processor { values, logger, logStream: stream, + workspacePath: path.join(this.workingDirectory, id), }; const job: Job = { @@ -81,11 +125,13 @@ export class JobProcessor implements Processor { throw new Error("Job is not in a 'PENDING' state"); } + await fs.mkdir(job.context.workspacePath); + job.status = 'STARTED'; try { for (const stage of job.stages) { - // Create a logger for each stage so we can create seperate + // Create a logger for each stage so we can create separate // Streams for each step. const { logger, log, stream } = makeLogStream({ id: job.id, @@ -105,8 +151,8 @@ export class JobProcessor implements Processor { logStream: stream, }); - // If the handler returns something, then let's merge this onto the ontext - // For the next stage to use as it might be relevant. + // If the handler returns something, then let's merge this onto the + // context for the next stage to use as it might be relevant. if (handlerResponse) { job.context = { ...job.context, @@ -120,7 +166,7 @@ export class JobProcessor implements Processor { // Log to the current stage the error that occurred and fail the stage. stage.status = 'FAILED'; logger.error(`Stage failed with error: ${error.message}`); - + logger.debug(error.stack); // Throw the error so the job can be failed too. throw error; } finally { @@ -135,6 +181,8 @@ export class JobProcessor implements Processor { // If something went wrong, fail the job, and set the error property on the job. job.error = { name: error.name, message: error.message }; job.status = 'FAILED'; + } finally { + await fs.remove(job.context.workspacePath); } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts index 73da47aed9..e45e6c8b96 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts @@ -15,18 +15,18 @@ */ import type { Writable } from 'stream'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { JsonValue } from '@backstage/config'; -import { RequiredTemplateValues } from '../stages/templater'; +import { TemplaterValues } from '../stages/templater'; import { Logger } from 'winston'; // Context will be a mutable object which is passed between stages // To share data, but also thinking that we can pass in functions here too // To maybe create sub steps or fail the entire thing, or skip stages down the line. export type StageContext = { - values: RequiredTemplateValues & Record; + values: TemplaterValues; entity: TemplateEntityV1alpha1; logger: Logger; logStream: Writable; + workspacePath: string; } & T; export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; @@ -58,7 +58,7 @@ export type Processor = { stages, }: { entity: TemplateEntityV1alpha1; - values: RequiredTemplateValues & Record; + values: TemplaterValues; stages: StageInput[]; }): Job; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts index 09bd6b7885..5c809d229e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts @@ -13,15 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - makeDeprecatedLocationTypeDetector, - parseLocationAnnotation, -} from './helpers'; +import { parseLocationAnnotation, joinGitUrlPath } from './helpers'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import { ConfigReader } from '@backstage/config'; describe('Helpers', () => { describe('parseLocationAnnotation', () => { @@ -30,10 +26,7 @@ describe('Helpers', () => { apiVersion: 'backstage.io/v1alpha1', kind: 'Template', metadata: { - annotations: { - // [LOCATION_ANNOTATION]: - // 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', - }, + annotations: {}, name: 'graphql-starter', title: 'GraphQL Service', description: @@ -80,7 +73,7 @@ describe('Helpers', () => { metadata: { annotations: { [LOCATION_ANNOTATION]: - ':https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', + ':https://github.com/o/r/blob/master/template.yaml', }, name: 'graphql-starter', title: 'GraphQL Service', @@ -258,28 +251,75 @@ describe('Helpers', () => { }); }); - describe('makeDeprecatedLocationTypeDetector', () => { - it('detects deprecated location types', () => { - const detector = makeDeprecatedLocationTypeDetector( - new ConfigReader({ - integrations: { - github: [{ host: 'derp.com' }, { host: 'foo.com' }], - gitlab: [{ host: 'derp.org' }, { host: 'foo.org' }], - azure: [{ host: 'derp.net' }, { host: 'foo.net' }], - }, - }), - ); - - expect(detector('http://lol:wut@derp.com/wat')).toBe('github'); - expect(detector('https://foo.com/wat')).toBe('github'); - expect(detector('http://derp.org:80/wat')).toBe('gitlab'); - expect(detector('https://foo.org/wat')).toBe('gitlab'); - expect(detector('http://not.derp.net')).toBe(undefined); - expect(detector('http://derp.net')).toBe('azure/api'); - expect(detector('http://derp.net:8080/wat')).toBe('azure/api'); - expect(detector('http://github.com')).toBe('github'); - expect(detector('http://gitlab.com')).toBe('gitlab'); - expect(detector('http://dev.azure.com')).toBe('azure/api'); + describe('joinGitUrlPath', () => { + it.each([ + [ + 'https://github.com/o/r/blob/master/template.yaml', + 'template', + 'https://github.com/o/r/blob/master/template', + ], + [ + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml', + undefined, + 'https://dev.azure.com/o/p/_git/template-repo?path=%2F', + ], + [ + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml', + 'a', + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa', + ], + [ + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Ftemplate.yaml', + 'b', + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Fb', + ], + [ + 'https://github.com/o/r/blob/master/template.yaml', + undefined, + 'https://github.com/o/r/blob/master', + ], + [ + 'https://github.com/o/r/blob/master/template.yaml', + 'template', + 'https://github.com/o/r/blob/master/template', + ], + [ + 'https://github.com/o/r/blob/master/templates/graphql-starter/template.yaml', + 'template', + 'https://github.com/o/r/blob/master/templates/graphql-starter/template', + ], + [ + 'https://gitlab.com/o/r/-/blob/master/template.yaml', + undefined, + 'https://gitlab.com/o/r/-/blob/master', + ], + [ + 'https://gitlab.com/o/r/-/blob/master/template.yaml', + 'template', + 'https://gitlab.com/o/r/-/blob/master/template', + ], + [ + 'https://gitlab.com/o/r/-/blob/master/a/b/c/template.yaml', + '../../c', + 'https://gitlab.com/o/r/-/blob/master/a/c', + ], + [ + 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', + undefined, + 'https://bitbucket.org/p/r/src/master/a/b', + ], + [ + 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', + 'c', + 'https://bitbucket.org/p/r/src/master/a/b/c', + ], + [ + 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', + '../c', + 'https://bitbucket.org/p/r/src/master/a/c', + ], + ])('should join git url %s with path %s', (url, path, result) => { + expect(joinGitUrlPath(url, path)).toBe(result); }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts index 21b63609e8..784e72ca87 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { posix as posixPath } from 'path'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; import { InputError } from '@backstage/backend-common'; -import { RemoteProtocol } from './types'; export type ParsedLocationAnnotation = { - protocol: RemoteProtocol; + protocol: 'file' | 'url'; location: string; }; @@ -40,7 +40,7 @@ export const parseLocationAnnotation = ( // split on the first colon for the protocol and the rest after the first split // is the location. const [protocol, location] = annotation.split(/:(.+)/) as [ - RemoteProtocol?, + ('file' | 'url')?, string?, ]; @@ -56,40 +56,19 @@ export const parseLocationAnnotation = ( }; }; -export type DeprecatedLocationTypeDetector = ( - url: string, -) => string | undefined; +export function joinGitUrlPath(repoUrl: string, path?: string): string { + const parsed = new URL(repoUrl); -// The reason for the existence of this is to help in migration to using mostly locations -// of type 'url'. This allows us to detect the deprecated location type based on the host, -// which we in turn can use to select out preparer or publisher. -// -// TODO(Rugvip): This should be removed in the future once we fully migrate to using -// integrations configuration for the scaffolder. -export function makeDeprecatedLocationTypeDetector( - config: Config, -): DeprecatedLocationTypeDetector { - const hostMap = new Map(); + if (parsed.hostname.endsWith('azure.com')) { + const templatePath = posixPath.normalize( + posixPath.join( + posixPath.dirname(parsed.searchParams.get('path') || '/'), + path || '.', + ), + ); + parsed.searchParams.set('path', templatePath); + return parsed.toString(); + } - // These are installed by default by the integrations - hostMap.set('github.com', 'github'); - hostMap.set('gitlab.com', 'gitlab'); - hostMap.set('dev.azure.com', 'azure/api'); - - config.getOptionalConfigArray('integrations.github')?.forEach(sub => { - hostMap.set(sub.getString('host'), 'github'); - }); - config.getOptionalConfigArray('integrations.gitlab')?.forEach(sub => { - hostMap.set(sub.getString('host'), 'gitlab'); - }); - config.getOptionalConfigArray('integrations.azure')?.forEach(sub => { - hostMap.set(sub.getString('host'), 'azure/api'); - }); - config.getOptionalConfigArray('integrations.bitbucket')?.forEach(sub => { - hostMap.set(sub.getString('host'), 'bitbucket'); - }); - return (url: string): string | undefined => { - const parsed = new URL(url); - return hostMap.get(parsed.hostname); - }; + return new URL(path || '.', repoUrl).toString().replace(/\/$/, ''); } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts new file mode 100644 index 0000000000..f151780ba9 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TemplateActionRegistry } from '../tasks/TemplateConverter'; +import { FilePreparer, PreparerBuilder } from './prepare'; +import Docker from 'dockerode'; +import { TemplaterBuilder, TemplaterValues } from './templater'; +import { PublisherBuilder } from './publish'; + +type Options = { + dockerClient: Docker; + preparers: PreparerBuilder; + templaters: TemplaterBuilder; + publishers: PublisherBuilder; +}; + +export function registerLegacyActions( + registry: TemplateActionRegistry, + options: Options, +) { + const { dockerClient, preparers, templaters, publishers } = options; + + registry.register({ + id: 'legacy:prepare', + async handler(ctx) { + const { protocol, url } = ctx.parameters; + const preparer = + protocol === 'file' ? new FilePreparer() : preparers.get(url as string); + + ctx.logger.info('Prepare the skeleton'); + await preparer.prepare({ + url: url as string, + logger: ctx.logger, + workspacePath: ctx.workspacePath, + }); + }, + }); + + registry.register({ + id: 'legacy:template', + async handler(ctx) { + const { logger } = ctx; + + const templater = templaters.get(ctx.parameters.templater as string); + + logger.info('Run the templater'); + await templater.run({ + workspacePath: ctx.workspacePath, + dockerClient, + logStream: ctx.logStream, + values: ctx.parameters.values as TemplaterValues, + }); + }, + }); + + registry.register({ + id: 'legacy:publish', + async handler(ctx) { + const { values } = ctx.parameters; + if ( + typeof values !== 'object' || + values === null || + Array.isArray(values) + ) { + throw new Error( + `Invalid values passed to publish, got ${typeof values}`, + ); + } + const storePath = values.storePath as unknown; + if (typeof storePath !== 'string') { + throw new Error( + `Invalid store path passed to publish, got ${typeof storePath}`, + ); + } + const owner = values.owner as unknown; + if (typeof owner !== 'string') { + throw new Error(`Invalid owner passed to publish, got ${typeof owner}`); + } + + const publisher = publishers.get(storePath); + ctx.logger.info('Will now store the template'); + const { remoteUrl, catalogInfoUrl } = await publisher.publish({ + values: { + ...values, + owner, + storePath, + }, + workspacePath: ctx.workspacePath, + logger: ctx.logger, + }); + ctx.output('remoteUrl', remoteUrl); + if (catalogInfoUrl) { + ctx.output('catalogInfoUrl', catalogInfoUrl); + } + }, + }); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 39efd1c62a..6637a68e4b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -14,140 +14,103 @@ * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; import { AzurePreparer } from './azure'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; + +jest.mock('fs-extra'); describe('AzurePreparer', () => { const mockGitClient = { clone: jest.fn(), }; + const logger = getVoidLogger(); + jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); - let mockEntity: TemplateEntityV1alpha1; - beforeEach(() => { - jest.clearAllMocks(); - mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; + const preparer = AzurePreparer.fromConfig({ + host: 'dev.azure.com', + token: 'fake-azure-token', }); - it('initializes git client with the correct arguments if an access token is provided for a repository', async () => { - const preparer = new AzurePreparer( - new ConfigReader({ - scaffolder: { - azure: { - api: { - token: 'fake-token', - }, - }, - }, - }), - ); - const logger = getVoidLogger(); - await preparer.prepare(mockEntity, { logger }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); + const prepareOptions = { + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + workspacePath, + logger, + }; + it('calls the clone command with token from integrations config', async () => { + await preparer.prepare(prepareOptions); expect(Git.fromAuth).toHaveBeenCalledWith({ - username: 'notempty', - password: 'fake-token', logger, + password: 'fake-azure-token', + username: 'notempty', }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); - it('calls the clone command with the correct arguments for a repository', async () => { - const preparer = new AzurePreparer(new ConfigReader({})); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + it('calls the clone command with the correct arguments for a repository', async () => { + await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: expect.any(String), + dir: checkoutPath, + }); + }); + + it('calls the clone command with the correct arguments for a repository with a specified branch', async () => { + await preparer.prepare({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml&version=GBmaster', + logger, + workspacePath, + }); + + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + dir: checkoutPath, + ref: 'master', }); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - const preparer = new AzurePreparer(new ConfigReader({})); - delete mockEntity.spec.path; - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + workspacePath, + logger, + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: expect.any(String), + dir: checkoutPath, }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); }); - it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = new AzurePreparer(new ConfigReader({})); - mockEntity.spec.path = './template/test/1/2/3'; - - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), + it('moves the template from path if it is specified', async () => { + await preparer.prepare({ + url: `https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=${encodeURIComponent( + './subdir', + )}`, + logger, + workspacePath, }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new AzurePreparer(new ConfigReader({})); - mockEntity.spec.path = './template/test/1/2/3'; - - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - workingDirectory: '/workDir', - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + expect(fs.move).toHaveBeenCalledWith( + path.resolve(checkoutPath, 'subdir'), + templatePath, ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts index a38c5f4fdc..9dc29be890 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -13,65 +13,51 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; -import { InputError, Git } from '@backstage/backend-common'; +import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; -import GitUriParser from 'git-url-parse'; -import { Config } from '@backstage/config'; +import parseGitUrl from 'git-url-parse'; +import { AzureIntegrationConfig } from '@backstage/integration'; export class AzurePreparer implements PreparerBase { - private readonly privateToken: string; - - constructor(config: Config) { - this.privateToken = - config.getOptionalString('scaffolder.azure.api.token') ?? ''; + static fromConfig(config: AzureIntegrationConfig) { + return new AzurePreparer({ token: config.token }); } - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - const { logger } = opts; + constructor(private readonly config: { token?: string }) {} - if (!['azure/api', 'url'].includes(protocol)) { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'url'`, - ); - } - const templateId = template.metadata.name; - - const parsedGitLocation = GitUriParser(location); - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), - ); - - const templateDirectory = path.join( - `${path.dirname(parsedGitLocation.filepath)}`, - template.spec.path ?? '.', + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath ?? '', ); // Username can be anything but the empty string according to: // https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#use-a-pat - const git = this.privateToken + const git = this.config.token ? Git.fromAuth({ - password: this.privateToken, + password: this.config.token, username: 'notempty', logger, }) : Git.fromAuth({ logger }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + ref: parsedGitUrl.ref, + dir: checkoutPath, }); - return path.resolve(tempDir, templateDirectory); + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index 809d18c808..faf7f0f6ff 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -14,22 +14,16 @@ * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; import { BitbucketPreparer } from './bitbucket'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; +import path from 'path'; +import os from 'os'; + +jest.mock('fs-extra'); describe('BitbucketPreparer', () => { - let mockEntity: TemplateEntityV1alpha1; + const logger = getVoidLogger(); const mockGitClient = { clone: jest.fn(), }; @@ -38,108 +32,82 @@ describe('BitbucketPreparer', () => { beforeEach(() => { jest.clearAllMocks(); - mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'bitbucket:https://bitbucket.org/backstage-project/backstage-repo', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; }); + const preparer = BitbucketPreparer.fromConfig({ + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-password', + }); + + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); + + const prepareOptions = { + url: 'https://bitbucket.org/backstage-project/backstage-repo', + logger, + workspacePath, + }; + it('calls the clone command with the correct arguments for a repository', async () => { - const preparer = new BitbucketPreparer(new ConfigReader({})); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: expect.any(String), + dir: checkoutPath, + ref: expect.any(String), }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => { - const preparer = new BitbucketPreparer( - new ConfigReader({ - integrations: { - bitbucket: [ - { - host: 'bitbucket.org', - username: 'fake-user', - appPassword: 'fake-password', - }, - ], - }, - }), - ); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: expect.any(String), + const preparerCheck = BitbucketPreparer.fromConfig({ + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-password', + }); + await preparerCheck.prepare(prepareOptions); + + expect(Git.fromAuth).toHaveBeenCalledWith({ + logger, + username: 'fake-user', + password: 'fake-password', }); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - const preparer = new BitbucketPreparer(new ConfigReader({})); - delete mockEntity.spec.path; - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: expect.any(String), + dir: checkoutPath, + ref: expect.any(String), }); }); - it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = new BitbucketPreparer(new ConfigReader({})); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), + it('moves a template subdirectory to checkout if specified', async () => { + await preparer.prepare({ + url: 'https://bitbucket.org/foo/bar/src/master/1/2/3', + logger, + workspacePath, }); - - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, + expect(fs.move).toHaveBeenCalledWith( + path.resolve(checkoutPath, '1', '2', '3'), + templatePath, ); }); - it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new BitbucketPreparer(new ConfigReader({})); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - workingDirectory: '/workDir', + it('calls the clone command with with token for auth method', async () => { + const preparerCheck = BitbucketPreparer.fromConfig({ + host: 'bitbucket.org', + token: 'fake-token', }); + await preparerCheck.prepare(prepareOptions); - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); + expect(Git.fromAuth).toHaveBeenCalledWith({ + logger, + username: 'x-token-auth', + password: 'fake-token', + }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index 5b244d24ce..d473656863 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -13,69 +13,70 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; -import { InputError, Git } from '@backstage/backend-common'; +import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; -import GitUriParser from 'git-url-parse'; -import { Config } from '@backstage/config'; +import { BitbucketIntegrationConfig } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; export class BitbucketPreparer implements PreparerBase { - private readonly privateToken: string; - private readonly username: string; - - constructor(config: Config) { - this.username = - config.getOptionalString('scaffolder.bitbucket.api.username') ?? ''; - this.privateToken = - config.getOptionalString('scaffolder.bitbucket.api.token') ?? ''; + static fromConfig(config: BitbucketIntegrationConfig) { + return new BitbucketPreparer({ + username: config.username, + token: config.token, + appPassword: config.appPassword, + }); } - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - const { logger } = opts; + constructor( + private readonly config: { + username?: string; + token?: string; + appPassword?: string; + }, + ) {} - if (!['bitbucket', 'url'].includes(protocol)) { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'url'`, - ); - } - const templateId = template.metadata.name; - - const repo = GitUriParser(location); - const repositoryCheckoutUrl = repo.toString('https'); - - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); - const templateDirectory = path.join( - `${path.dirname(repo.filepath)}`, - template.spec.path ?? '.', - ); - - const checkoutLocation = path.resolve(tempDir, templateDirectory); - - const git = this.privateToken - ? Git.fromAuth({ - username: this.username, - password: this.privateToken, - logger, - }) - : Git.fromAuth({ logger }); + const git = Git.fromAuth({ logger, ...this.getAuth() }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, + ref: parsedGitUrl.ref, }); - return checkoutLocation; + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } + } + + private getAuth(): { username: string; password: string } | undefined { + const { username, token, appPassword } = this.config; + + if (username && appPassword) { + return { username: username, password: appPassword }; + } + + if (token) { + return { + username: 'x-token-auth', + password: token! || appPassword!, + }; + } + + return undefined; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts index 2b328f420b..0c94edce42 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts @@ -14,57 +14,44 @@ * limitations under the License. */ -import os from 'os'; -import fs from 'fs-extra'; -import YAML from 'yaml'; -import { FilePreparer } from './file'; -import path from 'path'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger } from '@backstage/backend-common'; +import fs from 'fs-extra'; +import { FilePreparer } from './file'; +import os from 'os'; +import path from 'path'; -const setupTest = async (fixturePath: string) => { - const locationForTemplateYaml = path.resolve( - path.dirname( - require.resolve('@backstage/plugin-scaffolder-backend/package'), - ), - 'fixtures', - fixturePath, - ); - - const [parsedDocument] = YAML.parseAllDocuments( - await fs.readFile(locationForTemplateYaml, 'utf-8'), - ); - - const template: TemplateEntityV1alpha1 = parsedDocument.toJSON(); - template.metadata.annotations = { - [LOCATION_ANNOTATION]: `file:${locationForTemplateYaml}`, - }; - - const filePreparer = new FilePreparer(); - const resultDir = await filePreparer.prepare(template, { - logger: getVoidLogger(), - workingDirectory: os.tmpdir(), - }); - - return { filePreparer, template, resultDir }; -}; +jest.mock('fs-extra'); describe('File preparer', () => { - it('excludes the yaml file from the temp folder', async () => { - const { resultDir } = await setupTest('test-simple-template/template.yaml'); - expect(fs.existsSync(`${resultDir}/template.yaml`)).toBe(false); - }); + it('prepares templates from a file path', async () => { + const logger = getVoidLogger(); + const preparer = new FilePreparer(); + const root = os.platform() === 'win32' ? 'C:\\' : '/'; + const workspacePath = path.join(root, 'tmp'); + const checkoutPath = path.resolve(workspacePath, 'checkout'); - it('resolves relative path from the template', async () => { - const { resultDir } = await setupTest('test-simple-template/template.yaml'); - expect(fs.existsSync(`${resultDir}/expected_file.ts`)).toBe(true); - }); + await preparer.prepare({ + url: `file:///${root}path/to/template`, + logger, + workspacePath, + }); + expect(fs.copy).toHaveBeenCalledWith( + path.join(root, 'path', 'to', 'template'), + checkoutPath, + { + recursive: true, + }, + ); + expect(fs.ensureDir).toHaveBeenCalledWith(checkoutPath); - it('resolves relative path from the nested template', async () => { - const { resultDir } = await setupTest('test-nested-template/template.yaml'); - expect(fs.existsSync(`${resultDir}/expected_file.ts`)).toBe(true); + await expect( + preparer.prepare({ + url: 'file://not/full/path', + logger, + workspacePath, + }), + ).rejects.toThrow( + "Wrong location protocol, should be 'file', file://not/full/path", + ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts index 08129c7397..4d49ba222e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts @@ -13,43 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; +import { fileURLToPath } from 'url'; import { InputError } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; export class FilePreparer implements PreparerBase { - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - - if (protocol !== 'file') { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'file'`, - ); + async prepare({ url, workspacePath }: PreparerOptions) { + if (!url.startsWith('file:///')) { + throw new InputError(`Wrong location protocol, should be 'file', ${url}`); } - const templateId = template.metadata.name; - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), - ); + const checkoutDir = path.join(workspacePath, 'checkout'); + await fs.ensureDir(checkoutDir); - const parentDirectory = path.resolve( - path.dirname(location), - template.spec.path ?? '.', - ); + const templatePath = fileURLToPath(url); - await fs.copy(parentDirectory, tempDir, { - filter: src => src !== location, + await fs.copy(templatePath, checkoutDir, { recursive: true, }); - - return tempDir; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index aa040bfe58..301f015c1e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -14,124 +14,83 @@ * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; import { GithubPreparer } from './github'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; +jest.mock('fs-extra'); + describe('GitHubPreparer', () => { - let mockEntity: TemplateEntityV1alpha1; + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); + const mockGitClient = { clone: jest.fn(), }; + const logger = getVoidLogger(); jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); beforeEach(() => { jest.clearAllMocks(); - mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; }); + + const preparer = GithubPreparer.fromConfig({ + host: 'github.com', + token: 'fake-token', + }); + it('calls the clone command with the correct arguments for a repository', async () => { - const preparer = new GithubPreparer(); - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare({ + url: + 'https://github.com/benjdlambert/backstage-graphql-template/blob/master/templates/graphql-starter/template', + logger, + workspacePath, + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + dir: checkoutPath, + ref: expect.any(String), }); + expect(fs.move).toHaveBeenCalledWith( + path.resolve(checkoutPath, 'templates', 'graphql-starter', 'template'), + templatePath, + ); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); + it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - const preparer = new GithubPreparer(); - delete mockEntity.spec.path; - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare({ + url: + 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', + logger, + workspacePath, + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + dir: checkoutPath, + ref: 'master', }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); - it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = new GithubPreparer(); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), + it('calls the clone command with token', async () => { + await preparer.prepare({ + url: + 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', + logger, + workspacePath, }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new GithubPreparer(); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - workingDirectory: '/workDir', - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); - }); - - it('calls the clone command with the token when provided', async () => { - const preparer = new GithubPreparer({ token: 'abc' }); - const logger = getVoidLogger(); - - await preparer.prepare(mockEntity, { logger }); - expect(Git.fromAuth).toHaveBeenCalledWith({ logger, - username: 'abc', + username: 'fake-token', password: 'x-oauth-basic', }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index e7fa0c9b72..533aa8e005 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -13,63 +13,49 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; -import { InputError, Git } from '@backstage/backend-common'; +import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; -import GitUriParser from 'git-url-parse'; +import parseGitUrl from 'git-url-parse'; +import { GitHubIntegrationConfig } from '@backstage/integration'; export class GithubPreparer implements PreparerBase { - token?: string; - - constructor(params: { token?: string } = {}) { - this.token = params.token; + static fromConfig(config: GitHubIntegrationConfig) { + return new GithubPreparer({ token: config.token }); } - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - const { logger } = opts; + constructor(private readonly config: { token?: string }) {} - if (!['github', 'url'].includes(protocol)) { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'url'`, - ); - } - const templateId = template.metadata.name; - - const parsedGitLocation = GitUriParser(location); - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); - const templateDirectory = path.join( - `${path.dirname(parsedGitLocation.filepath)}`, - template.spec.path ?? '.', - ); - - const checkoutLocation = path.resolve(tempDir, templateDirectory); - - const git = this.token + const git = this.config.token ? Git.fromAuth({ - username: this.token, + username: this.config.token, password: 'x-oauth-basic', logger, }) : Git.fromAuth({ logger }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, + ref: parsedGitUrl.ref, }); - return checkoutLocation; + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 8dad349a13..6ed4651e4f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -13,165 +13,68 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; import { GitlabPreparer } from './gitlab'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; -import { ConfigReader } from '@backstage/config'; import { getVoidLogger, Git } from '@backstage/backend-common'; -const mockEntityWithProtocol = (protocol: string): TemplateEntityV1alpha1 => ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: `${protocol}:https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/template.yaml`, - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, -}); +jest.mock('fs-extra'); describe('GitLabPreparer', () => { - let mockEntity: TemplateEntityV1alpha1; + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); + const mockGitClient = { clone: jest.fn(), }; + const logger = getVoidLogger(); jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); beforeEach(() => { jest.clearAllMocks(); }); + const preparer = GitlabPreparer.fromConfig({ + host: 'gitlab.com', + token: 'fake-token', + }); - ['gitlab', 'gitlab/api'].forEach(protocol => { - it(`calls the clone command with the correct arguments for a repository using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer(new ConfigReader({})); - mockEntity = mockEntityWithProtocol(protocol); - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), - }); + it(`calls the clone command with the correct arguments for a repository`, async () => { + await preparer.prepare({ + url: + 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master', + logger, + workspacePath, }); - it(`calls the clone command with the correct arguments if an access token is provided in integrations for a repository using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer( - new ConfigReader({ - integrations: { - gitlab: [ - { - host: 'gitlab.com', - token: 'fake-token', - }, - ], - }, - }), - ); - mockEntity = mockEntityWithProtocol(protocol); - const logger = getVoidLogger(); - - await preparer.prepare(mockEntity, { logger }); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - username: 'oauth2', - password: 'fake-token', - }); + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: 'https://gitlab.com/benjdlambert/backstage-graphql-template.git', + dir: checkoutPath, + ref: expect.any(String), }); - it(`calls the clone command with the correct arguments if an access token is provided in scaffolder for a repository using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer( - new ConfigReader({ - scaffolder: { - gitlab: { api: { token: 'fake-token' } }, - }, - }), - ); - mockEntity = mockEntityWithProtocol(protocol); - const logger = getVoidLogger(); - - await preparer.prepare(mockEntity, { logger }); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - username: 'oauth2', - password: 'fake-token', - }); + expect(Git.fromAuth).toHaveBeenCalledWith({ + logger, + username: 'oauth2', + password: 'fake-token', }); - it(`calls the clone command with the correct arguments for a repository when no path is provided using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer(new ConfigReader({})); - mockEntity = mockEntityWithProtocol(protocol); - delete mockEntity.spec.path; + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); + }); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), - }); - }); - - it(`return the temp directory with the path to the folder if it is specified using the ${protocol} protocol`, async () => { - const preparer = new GitlabPreparer(new ConfigReader({})); - mockEntity = mockEntityWithProtocol(protocol); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = new GitlabPreparer(new ConfigReader({})); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - workingDirectory: '/workDir', - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); + it(`clones the template from a sub directory if specified`, async () => { + await preparer.prepare({ + url: + 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/1/2/3', + logger, + workspacePath, }); + expect(fs.move).toHaveBeenCalledWith( + path.resolve(checkoutPath, '1', '2', '3'), + templatePath, + ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index 6e169baa98..fa5c8c1325 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -13,80 +13,50 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { InputError, Git } from '@backstage/backend-common'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; -import { - GitLabIntegrationConfig, - readGitLabIntegrationConfigs, -} from '@backstage/integration'; +import { Git } from '@backstage/backend-common'; +import { GitLabIntegrationConfig } from '@backstage/integration'; import fs from 'fs-extra'; -import GitUriParser from 'git-url-parse'; -import os from 'os'; +import parseGitUrl from 'git-url-parse'; import path from 'path'; -import { parseLocationAnnotation } from '../helpers'; import { PreparerBase, PreparerOptions } from './types'; export class GitlabPreparer implements PreparerBase { - private readonly integrations: GitLabIntegrationConfig[]; - private readonly scaffolderToken: string | undefined; - - constructor(config: Config) { - this.integrations = readGitLabIntegrationConfigs( - config.getOptionalConfigArray('integrations.gitlab') ?? [], - ); - this.scaffolderToken = config.getOptionalString( - 'scaffolder.gitlab.api.token', - ); + static fromConfig(config: GitLabIntegrationConfig) { + return new GitlabPreparer({ token: config.token }); } - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const { logger } = opts; - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); + constructor(private readonly config: { token?: string }) {} - if (!['gitlab', 'gitlab/api', 'url'].includes(protocol)) { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'url'`, - ); - } - const templateId = template.metadata.name; - - const parsedGitLocation = GitUriParser(location); - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); + parsedGitUrl.git_suffix = true; - const templateDirectory = path.join( - `${path.dirname(parsedGitLocation.filepath)}`, - template.spec.path ?? '.', - ); - - const token = this.getToken(parsedGitLocation.resource); - const git = token + const git = this.config.token ? Git.fromAuth({ - password: token, + password: this.config.token, username: 'oauth2', logger, }) : Git.fromAuth({ logger }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, + ref: parsedGitUrl.ref, }); - return path.resolve(tempDir, templateDirectory); - } + await fs.move(fullPathToTemplate, targetPath); - private getToken(host: string): string | undefined { - return ( - this.scaffolderToken || - this.integrations.find(c => c.host === host)?.token - ); + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts index f7bc535189..80db93d00f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts @@ -13,9 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './preparers'; -export * from './types'; -export * from './file'; -export * from './github'; -export * from './gitlab'; -export * from './azure'; + +export { AzurePreparer } from './azure'; +export { BitbucketPreparer } from './bitbucket'; +export { FilePreparer } from './file'; +export { GithubPreparer } from './github'; +export { GitlabPreparer } from './gitlab'; +export { Preparers } from './preparers'; +export type { PreparerBase, PreparerBuilder, PreparerOptions } from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts index 7c2d7e0a47..8dd85690cb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts @@ -14,109 +14,36 @@ * limitations under the License. */ import { Preparers } from '.'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { FilePreparer } from './file'; +import { GithubPreparer } from './github'; describe('Preparers', () => { - const mockTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml', - }, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - templater: 'cookiecutter', - path: '.', - type: 'website', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; - it('should throw an error when the preparer for the source location is not registered', () => { + it('should return the correct preparer based on the hostname', async () => { + const preparer = await GithubPreparer.fromConfig({ + host: 'github.com', + apiBaseUrl: 'lols', + token: 'something else yo', + }); + const preparers = new Preparers(); + preparers.register('github.com', preparer); - expect(() => preparers.get(mockTemplate)).toThrow( - expect.objectContaining({ - message: 'No preparer registered for type: "file"', - }), - ); - }); - it('should return the correct preparer when the source matches', () => { - const preparers = new Preparers(); - const preparer = new FilePreparer(); - - preparers.register('file', preparer); - - expect(preparers.get(mockTemplate)).toBe(preparer); + expect( + preparers.get('https://github.com/please/find/me/something/from/github'), + ).toBe(preparer); }); - it('should throw an error if the metadata tag does not exist in the entity', () => { - const brokenTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: {}, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: '.', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; + it('should throw an error if there is nothing that will match the url provided', async () => { + const preparer = await GithubPreparer.fromConfig({ + host: 'github.com', + apiBaseUrl: 'lols', + token: 'something else yo', + }); const preparers = new Preparers(); + preparers.register('github.com', preparer); - expect(() => preparers.get(brokenTemplate)).toThrow( - expect.objectContaining({ - message: expect.stringContaining('No location annotation provided'), - }), + expect(() => preparers.get('https://404.com')).toThrow( + `Unable to find a preparer for URL: https://404.com. Please make sure to register this host under an integration in app-config`, ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts index 906fc0f9fd..a0b477b66a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts @@ -15,95 +15,65 @@ */ import { Config } from '@backstage/config'; -import { Logger } from 'winston'; import { PreparerBase, PreparerBuilder } from './types'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { - DeprecatedLocationTypeDetector, - makeDeprecatedLocationTypeDetector, - parseLocationAnnotation, -} from '../helpers'; -import { RemoteProtocol } from '../types'; -import { FilePreparer } from './file'; +import { Logger } from 'winston'; + import { GitlabPreparer } from './gitlab'; import { AzurePreparer } from './azure'; import { GithubPreparer } from './github'; import { BitbucketPreparer } from './bitbucket'; +import { ScmIntegrations } from '@backstage/integration'; export class Preparers implements PreparerBuilder { - private preparerMap = new Map(); + private preparerMap = new Map(); - constructor(private readonly typeDetector?: DeprecatedLocationTypeDetector) {} - - register(protocol: RemoteProtocol, preparer: PreparerBase) { - this.preparerMap.set(protocol, preparer); + register(host: string, preparer: PreparerBase) { + this.preparerMap.set(host, preparer); } - get(template: TemplateEntityV1alpha1): PreparerBase { - const { protocol, location } = parseLocationAnnotation(template); - - const preparer = this.preparerMap.get(protocol); - + get(url: string): PreparerBase { + const preparer = this.preparerMap.get(new URL(url).host); if (!preparer) { - if ((protocol as string) === 'url') { - const type = this.typeDetector?.(location); - const detected = type && this.preparerMap.get(type as RemoteProtocol); - if (detected) { - return detected; - } - if (type) { - throw new Error( - `No preparer configuration available for type '${type}' with url "${location}". ` + - "Make sure you've added appropriate configuration in the 'scaffolder' configuration section", - ); - } else { - throw new Error( - `Failed to detect preparer type. Unable to determine integration type for location "${location}". ` + - "Please add appropriate configuration to the 'integrations' configuration section", - ); - } - } - throw new Error(`No preparer registered for type: "${protocol}"`); + throw new Error( + `Unable to find a preparer for URL: ${url}. Please make sure to register this host under an integration in app-config`, + ); } - return preparer; } static async fromConfig( config: Config, - { logger }: { logger: Logger }, + // eslint-disable-next-line + _: { logger: Logger }, ): Promise { - const typeDetector = makeDeprecatedLocationTypeDetector(config); + const preparers = new Preparers(); + const scm = ScmIntegrations.fromConfig(config); + for (const integration of scm.azure.list()) { + preparers.register( + integration.config.host, + AzurePreparer.fromConfig(integration.config), + ); + } - const preparers = new Preparers(typeDetector); + for (const integration of scm.github.list()) { + preparers.register( + integration.config.host, + GithubPreparer.fromConfig(integration.config), + ); + } - const filePreparer = new FilePreparer(); - const gitlabPreparer = new GitlabPreparer(config); - const azurePreparer = new AzurePreparer(config); - const bitbucketPreparer = new BitbucketPreparer(config); + for (const integration of scm.gitlab.list()) { + preparers.register( + integration.config.host, + GitlabPreparer.fromConfig(integration.config), + ); + } - preparers.register('file', filePreparer); - preparers.register('gitlab', gitlabPreparer); - preparers.register('gitlab/api', gitlabPreparer); - preparers.register('azure/api', azurePreparer); - preparers.register('bitbucket', bitbucketPreparer); - - const githubConfig = config.getOptionalConfig('scaffolder.github'); - if (githubConfig) { - try { - const githubToken = githubConfig.getString('token'); - const githubPreparer = new GithubPreparer({ token: githubToken }); - - preparers.register('github', githubPreparer); - } catch (e) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize github scaffolding provider, ${e.message}`, - ); - } - - logger.warn(`Skipping github scaffolding provider, ${e.message}`); - } + for (const integration of scm.bitbucket.list()) { + preparers.register( + integration.config.host, + BitbucketPreparer.fromConfig(integration.config), + ); } return preparers; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts index 4936461c5c..6a2b7808f1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts @@ -13,28 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; -import { RemoteProtocol } from '../types'; export type PreparerOptions = { + /** + * Full URL to the directory containg template data + */ + url: string; + /** + * The workspace path that will eventually be the the root of the new repo + */ + workspacePath: string; logger: Logger; - workingDirectory?: string; }; -export type PreparerBase = { +export interface PreparerBase { /** - * Given an Entity definition from the Service Catalog, go and prepare a directory - * with contents from the remote location in temporary storage and return the path - * @param template The template entity from the Service Catalog + * Prepare a directory with contents from the remote location */ - prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise; -}; + prepare(opts: PreparerOptions): Promise; +} export type PreparerBuilder = { - register(protocol: RemoteProtocol, preparer: PreparerBase): void; - get(template: TemplateEntityV1alpha1): PreparerBase; + register(host: string, preparer: PreparerBase): void; + get(url: string): PreparerBase; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts index f1e0cd6175..1ce47e04dd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts @@ -15,55 +15,73 @@ */ jest.mock('./helpers'); +jest.mock('azure-devops-node-api', () => ({ + WebApi: jest.fn(), + getPersonalAccessTokenHandler: jest.fn().mockReturnValue(() => {}), +})); + +import os from 'os'; +import { resolve } from 'path'; import { AzurePublisher } from './azure'; -import { GitApi } from 'azure-devops-node-api/GitApi'; +import { WebApi } from 'azure-devops-node-api'; import * as helpers from './helpers'; import { getVoidLogger } from '@backstage/backend-common'; -const { mockGitApi } = require('azure-devops-node-api/GitApi') as { - mockGitApi: { - createRepository: jest.MockedFunction; - }; -}; - describe('Azure Publisher', () => { - const publisher = new AzurePublisher(new GitApi('', []), 'fake-token'); const logger = getVoidLogger(); - beforeEach(() => { - jest.clearAllMocks(); - }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); describe('publish: createRemoteInAzure', () => { it('should use azure-devops-node-api to create a repo in the given project', async () => { - mockGitApi.createRepository.mockResolvedValue({ + const mockGitClient = { + createRepository: jest.fn(), + }; + const mockGitApi = { + getGitApi: jest.fn().mockReturnValue(mockGitClient), + }; + + ((WebApi as unknown) as jest.Mock).mockImplementation(() => mockGitApi); + + const publisher = await AzurePublisher.fromConfig({ + host: 'dev.azure.com', + token: 'fake-azure-token', + }); + + mockGitClient.createRepository.mockResolvedValue({ remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', } as { remoteUrl: string }); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'project/repo', + storePath: 'https://dev.azure.com/organisation/project/_git/repo', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); + expect(WebApi).toHaveBeenCalledWith( + 'https://dev.azure.com/organisation', + expect.any(Function), + ); + expect(result).toEqual({ remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', catalogInfoUrl: 'https://dev.azure.com/organization/project/_git/repo?path=%2Fcatalog-info.yaml', }); - expect(mockGitApi.createRepository).toHaveBeenCalledWith( + expect(mockGitClient.createRepository).toHaveBeenCalledWith( { name: 'repo', }, 'project', ); expect(helpers.initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', - auth: { username: 'notempty', password: 'fake-token' }, + auth: { username: 'notempty', password: 'fake-azure-token' }, logger, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts index ffe6845f01..4190ba8abb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts @@ -15,35 +15,53 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { GitApi } from 'azure-devops-node-api/GitApi'; +import { IGitApi } from 'azure-devops-node-api/GitApi'; import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/GitInterfaces'; -import { JsonValue } from '@backstage/config'; -import { RequiredTemplateValues } from '../templater'; import { initRepoAndPush } from './helpers'; +import { AzureIntegrationConfig } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; +import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; +import path from 'path'; export class AzurePublisher implements PublisherBase { - private readonly client: GitApi; - private readonly token: string; - - constructor(client: GitApi, token: string) { - this.client = client; - this.token = token; + static async fromConfig(config: AzureIntegrationConfig) { + if (!config.token) { + return undefined; + } + return new AzurePublisher({ token: config.token }); } + constructor(private readonly config: { token: string }) {} + async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { - const remoteUrl = await this.createRemote(values); + const { owner, name, organization, resource } = parseGitUrl( + values.storePath, + ); + const authHandler = getPersonalAccessTokenHandler(this.config.token); + const webApi = new WebApi( + `https://${resource}/${organization}`, + authHandler, + ); + const client = await webApi.getGitApi(); + + const remoteUrl = await this.createRemote({ + project: owner, + name, + client, + }); + const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`; await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl, auth: { username: 'notempty', - password: this.token, + password: this.config.token, }, logger, }); @@ -51,13 +69,14 @@ export class AzurePublisher implements PublisherBase { return { remoteUrl, catalogInfoUrl }; } - private async createRemote( - values: RequiredTemplateValues & Record, - ) { - const [project, name] = values.storePath.split('/'); - + private async createRemote(opts: { + name: string; + project: string; + client: IGitApi; + }) { + const { name, project, client } = opts; const createOptions: GitRepositoryCreateOptions = { name }; - const repo = await this.client.createRepository(createOptions, project); + const repo = await client.createRepository(createOptions, project); return repo.remoteUrl || ''; } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts index 3b084f1bf9..4cf5e83ba3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -16,6 +16,8 @@ jest.mock('./helpers'); +import os from 'os'; +import { resolve } from 'path'; import { BitbucketPublisher } from './bitbucket'; import { initRepoAndPush } from './helpers'; import { getVoidLogger } from '@backstage/backend-common'; @@ -32,6 +34,9 @@ describe('Bitbucket Publisher', () => { jest.clearAllMocks(); }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); + describe('publish: createRemoteInBitbucketCloud', () => { it('should create repo in bitbucket cloud', async () => { server.use( @@ -58,18 +63,21 @@ describe('Bitbucket Publisher', () => { ), ); - const publisher = new BitbucketPublisher( - 'https://bitbucket.org', - 'fake-user', - 'fake-token', + const publisher = await BitbucketPublisher.fromConfig( + { + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-token', + }, + { repoVisibility: 'private' }, ); const result = await publisher.publish({ values: { - storePath: 'project/repo', + storePath: 'https://bitbucket.org/project/repo', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger: logger, }); @@ -80,13 +88,14 @@ describe('Bitbucket Publisher', () => { }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://bitbucket.org/project/repo', auth: { username: 'fake-user', password: 'fake-token' }, logger: logger, }); }); }); + describe('publish: createRemoteInBitbucketServer', () => { it('should create repo in bitbucket server', async () => { server.use( @@ -116,18 +125,20 @@ describe('Bitbucket Publisher', () => { ), ); - const publisher = new BitbucketPublisher( - 'https://bitbucket.mycompany.com', - 'fake-user', - 'fake-token', + const publisher = await BitbucketPublisher.fromConfig( + { + host: 'bitbucket.mycompany.com', + token: 'fake-token', + }, + { repoVisibility: 'private' }, ); const result = await publisher.publish({ values: { - storePath: 'project/repo', + storePath: 'https://bitbucket.mycompany.com/project/repo', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger: logger, }); @@ -138,9 +149,9 @@ describe('Bitbucket Publisher', () => { }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', - auth: { username: 'fake-user', password: 'fake-token' }, + auth: { username: 'x-token-auth', password: 'fake-token' }, logger: logger, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index ad83ad54e8..373920c005 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -16,62 +16,99 @@ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; import { initRepoAndPush } from './helpers'; -import { RequiredTemplateValues } from '../templater'; -import { JsonValue } from '../../../../../../packages/config/src'; import fetch from 'cross-fetch'; +import { BitbucketIntegrationConfig } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; +import path from 'path'; +export type RepoVisibilityOptions = 'private' | 'public'; + +// TODO(blam): We should probably start to use a bitbucket client here that we can change +// the baseURL to point at on-prem or public bitbucket versions like we do for +// github and ghe. There's to much logic and not enough types here for us to say that this way is better than using +// a supported bitbucket client if one exists. export class BitbucketPublisher implements PublisherBase { - private readonly host: string; - private readonly username: string; - private readonly token: string; - - constructor(host: string, username: string, token: string) { - this.host = host; - this.username = username; - this.token = token; + static async fromConfig( + config: BitbucketIntegrationConfig, + { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, + ) { + return new BitbucketPublisher({ + host: config.host, + token: config.token, + appPassword: config.appPassword, + username: config.username, + repoVisibility, + }); } + constructor( + private readonly config: { + host: string; + token?: string; + appPassword?: string; + username?: string; + repoVisibility: RepoVisibilityOptions; + }, + ) {} + async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { - const result = await this.createRemote(values); + const { owner: project, name } = parseGitUrl(values.storePath); + + const description = values.description as string; + const result = await this.createRemote({ + project, + name, + description, + }); await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl: result.remoteUrl, auth: { - username: this.username, - password: this.token, + username: this.config.username ? this.config.username : 'x-token-auth', + password: this.config.appPassword + ? this.config.appPassword + : this.config.token ?? '', }, logger, }); return result; } - private async createRemote( - values: RequiredTemplateValues & Record, - ): Promise { - if (this.host === 'https://bitbucket.org') { - return this.createBitbucketCloudRepository(values); + private async createRemote(opts: { + project: string; + name: string; + description: string; + }): Promise { + if (this.config.host === 'bitbucket.org') { + return this.createBitbucketCloudRepository(opts); } - return this.createBitbucketServerRepository(values); + return this.createBitbucketServerRepository(opts); } - private async createBitbucketCloudRepository( - values: RequiredTemplateValues & Record, - ): Promise { - const [project, name] = values.storePath.split('/'); + private async createBitbucketCloudRepository(opts: { + project: string; + name: string; + description: string; + }): Promise { + const { project, name, description } = opts; let response: Response; - const buffer = Buffer.from(`${this.username}:${this.token}`, 'utf8'); + const buffer = Buffer.from( + `${this.config.username}:${this.config.appPassword}`, + 'utf8', + ); const options: RequestInit = { method: 'POST', body: JSON.stringify({ scm: 'git', - description: values.description, + description: description, + is_private: this.config.repoVisibility === 'private', }), headers: { Authorization: `Basic ${buffer.toString('base64')}`, @@ -102,26 +139,29 @@ export class BitbucketPublisher implements PublisherBase { throw new Error(`Not a valid response code ${await response.text()}`); } - private async createBitbucketServerRepository( - values: RequiredTemplateValues & Record, - ): Promise { - const [project, name] = values.storePath.split('/'); + private async createBitbucketServerRepository(opts: { + project: string; + name: string; + description: string; + }): Promise { + const { project, name, description } = opts; let response: Response; const options: RequestInit = { method: 'POST', body: JSON.stringify({ name: name, - description: values.description, + description: description, + is_private: this.config.repoVisibility === 'private', }), headers: { - Authorization: `Bearer ${this.token}`, + Authorization: `Bearer ${this.config.token}`, 'Content-Type': 'application/json', }, }; try { response = await fetch( - `${this.host}/rest/api/1.0/projects/${project}/repos`, + `https://${this.config.host}/rest/api/1.0/projects/${project}/repos`, options, ); } catch (e) { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts index 22f1cd3172..30fe8fd65a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -17,15 +17,13 @@ jest.mock('@octokit/rest'); jest.mock('./helpers'); -import { Octokit } from '@octokit/rest'; -import { - OctokitResponse, - ReposCreateInOrgResponseData, - UsersGetByUsernameResponseData, -} from '@octokit/types'; +import os from 'os'; +import { resolve } from 'path'; + +import { getVoidLogger } from '@backstage/backend-common'; +import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; import { GithubPublisher } from './github'; import { initRepoAndPush } from './helpers'; -import { getVoidLogger } from '@backstage/backend-common'; const { mockGithubClient } = require('@octokit/rest') as { mockGithubClient: { @@ -41,33 +39,38 @@ describe('GitHub Publisher', () => { jest.clearAllMocks(); }); - describe('with public repo visibility', () => { - const publisher = new GithubPublisher({ - client: new Octokit(), - token: 'abc', - repoVisibility: 'public', - }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); + describe('with public repo visibility', () => { describe('publish: createRemoteInGithub', () => { it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'public' }, + ); + mockGithubClient.repos.createInOrg.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['repos']['createInOrg']['response']); mockGithubClient.users.getByUsername.mockResolvedValue({ data: { type: 'Organization', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', access: 'blam/team', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -92,32 +95,40 @@ describe('GitHub Publisher', () => { permission: 'admin', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'public' }, + ); + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']); mockGithubClient.users.getByUsername.mockResolvedValue({ data: { type: 'User', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', access: 'blam', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -135,34 +146,42 @@ describe('GitHub Publisher', () => { expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); }); it('should invite other user in the authed user', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'public' }, + ); + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']); mockGithubClient.users.getByUsername.mockResolvedValue({ data: { type: 'User', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', access: 'bob', description: 'description', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -185,40 +204,42 @@ describe('GitHub Publisher', () => { permission: 'admin', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); }); describe('with internal repo visibility', () => { - const publisher = new GithubPublisher({ - client: new Octokit(), - token: 'abc', - repoVisibility: 'internal', - }); - it('creates a private repository in the organization with visibility set to internal', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'internal' }, + ); + mockGithubClient.repos.createInOrg.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['repos']['createInOrg']['response']); mockGithubClient.users.getByUsername.mockResolvedValue({ data: { type: 'Organization', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { isOrg: true, - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -234,39 +255,41 @@ describe('GitHub Publisher', () => { visibility: 'internal', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); }); describe('private visibility in a user account', () => { - const publisher = new GithubPublisher({ - client: new Octokit(), - token: 'abc', - repoVisibility: 'private', - }); - it('creates a private repository', async () => { + const publisher = await GithubPublisher.fromConfig( + { + token: 'fake-token', + host: 'github.com', + }, + { repoVisibility: 'private' }, + ); + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ data: { clone_url: 'https://github.com/backstage/backstage.git', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['repos']['createForAuthenticatedUser']['response']); mockGithubClient.users.getByUsername.mockResolvedValue({ data: { type: 'User', }, - } as OctokitResponse); + } as RestEndpointMethodTypes['users']['getByUsername']['response']); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'blam/test', + storePath: 'https://github.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -282,9 +305,9 @@ describe('GitHub Publisher', () => { private: true, }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', - auth: { username: 'abc', password: 'x-oauth-basic' }, + auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 39a588882f..13aab7ff34 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -15,46 +15,64 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { Octokit } from '@octokit/rest'; import { initRepoAndPush } from './helpers'; -import { JsonValue } from '@backstage/config'; -import { RequiredTemplateValues } from '../templater'; +import { GitHubIntegrationConfig } from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; +import { Octokit } from '@octokit/rest'; +import path from 'path'; export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; -interface GithubPublisherParams { - client: Octokit; - token: string; - repoVisibility: RepoVisibilityOptions; -} - export class GithubPublisher implements PublisherBase { - private client: Octokit; - private token: string; - private repoVisibility: RepoVisibilityOptions; + static async fromConfig( + config: GitHubIntegrationConfig, + { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, + ) { + if (!config.token) { + return undefined; + } - constructor({ - client, - token, - repoVisibility = 'public', - }: GithubPublisherParams) { - this.client = client; - this.token = token; - this.repoVisibility = repoVisibility; + const githubClient = new Octokit({ + auth: config.token, + baseUrl: config.apiBaseUrl, + }); + + return new GithubPublisher({ + token: config.token, + client: githubClient, + repoVisibility, + }); } + constructor( + private readonly config: { + token: string; + client: Octokit; + repoVisibility: RepoVisibilityOptions; + }, + ) {} + async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { - const remoteUrl = await this.createRemote(values); + const { owner, name } = parseGitUrl(values.storePath); + + const description = values.description as string; + const access = values.access as string; + const remoteUrl = await this.createRemote({ + description, + access, + name, + owner, + }); await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl, auth: { - username: this.token, + username: this.config.token, password: 'x-oauth-basic', }, logger, @@ -64,39 +82,41 @@ export class GithubPublisher implements PublisherBase { /\.git$/, '/blob/master/catalog-info.yaml', ); - return { remoteUrl, catalogInfoUrl }; } - private async createRemote( - values: RequiredTemplateValues & Record, - ) { - const [owner, name] = values.storePath.split('/'); - const description = values.description as string; + private async createRemote(opts: { + access: string; + name: string; + owner: string; + description: string; + }) { + const { access, description, owner, name } = opts; - const user = await this.client.users.getByUsername({ username: owner }); + const user = await this.config.client.users.getByUsername({ + username: owner, + }); const repoCreationPromise = user.data.type === 'Organization' - ? this.client.repos.createInOrg({ + ? this.config.client.repos.createInOrg({ name, org: owner, - private: this.repoVisibility !== 'public', - visibility: this.repoVisibility, + private: this.config.repoVisibility !== 'public', + visibility: this.config.repoVisibility, description, }) - : this.client.repos.createForAuthenticatedUser({ + : this.config.client.repos.createForAuthenticatedUser({ name, - private: this.repoVisibility === 'private', + private: this.config.repoVisibility === 'private', description, }); const { data } = await repoCreationPromise; - const access = values.access as string; if (access?.startsWith(`${owner}/`)) { const [, team] = access.split('/'); - await this.client.teams.addOrUpdateRepoPermissionsInOrg({ + await this.config.client.teams.addOrUpdateRepoPermissionsInOrg({ org: owner, team_slug: team, owner, @@ -105,7 +125,7 @@ export class GithubPublisher implements PublisherBase { }); // no need to add access if it's the person who own's the personal account } else if (access && access !== owner) { - await this.client.repos.addCollaborator({ + await this.config.client.repos.addCollaborator({ owner, repo: name, username: access, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index 2f33358829..61c8480efb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -14,33 +14,55 @@ * limitations under the License. */ -jest.mock('@gitbeaker/node'); +jest.mock('@gitbeaker/node', () => ({ + Gitlab: jest.fn(), +})); + jest.mock('./helpers'); +import os from 'os'; +import path from 'path'; import { GitlabPublisher } from './gitlab'; -import { Gitlab as GitlabAPI } from '@gitbeaker/core'; import { Gitlab } from '@gitbeaker/node'; import { initRepoAndPush } from './helpers'; import { getVoidLogger } from '@backstage/backend-common'; -const { mockGitlabClient } = require('@gitbeaker/node') as { - mockGitlabClient: { - Namespaces: jest.Mocked; - Projects: jest.Mocked; - Users: jest.Mocked; - }; -}; - describe('GitLab Publisher', () => { const logger = getVoidLogger(); - const publisher = new GitlabPublisher(new Gitlab({}), 'fake-token'); + const mockGitlabClient = { + Namespaces: { + show: jest.fn(), + }, + Projects: { + create: jest.fn(), + }, + Users: { + current: jest.fn(), + }, + }; beforeEach(() => { jest.clearAllMocks(); + + ((Gitlab as unknown) as jest.Mock).mockImplementation( + () => mockGitlabClient, + ); }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = path.resolve(workspacePath, 'result'); + describe('publish: createRemoteInGitLab', () => { it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => { + const publisher = await GitlabPublisher.fromConfig( + { + host: 'gitlab.com', + token: 'fake-token', + baseUrl: 'https://gitlab.hosted.com', + }, + { repoVisibility: 'public' }, + ); + mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 42, } as { id: number }); @@ -48,23 +70,31 @@ describe('GitLab Publisher', () => { http_url_to_repo: 'mockclone', } as { http_url_to_repo: string }); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { isOrg: true, - storePath: 'blam/test', + storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); - expect(result).toEqual({ remoteUrl: 'mockclone' }); + expect(Gitlab).toHaveBeenCalledWith({ + token: 'fake-token', + host: 'https://gitlab.hosted.com', + }); + expect(result).toEqual({ + remoteUrl: 'mockclone', + catalogInfoUrl: 'mockclone', + }); expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 42, name: 'test', + visibility: 'public', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'mockclone', auth: { username: 'oauth2', password: 'fake-token' }, logger, @@ -72,6 +102,14 @@ describe('GitLab Publisher', () => { }); it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { + const publisher = await GitlabPublisher.fromConfig( + { + host: 'gitlab.com', + token: 'fake-token', + }, + { repoVisibility: 'public' }, + ); + mockGitlabClient.Namespaces.show.mockResolvedValue({}); mockGitlabClient.Users.current.mockResolvedValue({ id: 21, @@ -80,23 +118,27 @@ describe('GitLab Publisher', () => { http_url_to_repo: 'mockclone', } as { http_url_to_repo: string }); - const result = await publisher.publish({ + const result = await publisher!.publish({ values: { - storePath: 'blam/test', + storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); - expect(result).toEqual({ remoteUrl: 'mockclone' }); + expect(result).toEqual({ + remoteUrl: 'mockclone', + catalogInfoUrl: 'mockclone', + }); expect(mockGitlabClient.Users.current).toHaveBeenCalled(); expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 21, name: 'test', + visibility: 'public', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'mockclone', auth: { username: 'oauth2', password: 'fake-token' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts index 156a4a249c..4b10d653da 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts @@ -15,56 +15,90 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { Gitlab } from '@gitbeaker/core'; -import { JsonValue } from '@backstage/config'; +import { Gitlab } from '@gitbeaker/node'; +import { Gitlab as GitlabClient } from '@gitbeaker/core'; import { initRepoAndPush } from './helpers'; -import { RequiredTemplateValues } from '../templater'; +import parseGitUrl from 'git-url-parse'; +import path from 'path'; +import { GitLabIntegrationConfig } from '@backstage/integration'; + +export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; export class GitlabPublisher implements PublisherBase { - private readonly client: Gitlab; - private readonly token: string; + static async fromConfig( + config: GitLabIntegrationConfig, + { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, + ) { + if (!config.token) { + return undefined; + } - constructor(client: Gitlab, token: string) { - this.client = client; - this.token = token; + const client = new Gitlab({ host: config.baseUrl, token: config.token }); + return new GitlabPublisher({ + token: config.token, + client, + repoVisibility, + }); } + constructor( + private readonly config: { + token: string; + client: GitlabClient; + repoVisibility: RepoVisibilityOptions; + }, + ) {} + async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { - const remoteUrl = await this.createRemote(values); + const { owner, name } = parseGitUrl(values.storePath); + + const remoteUrl = await this.createRemote({ + owner, + name, + }); await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl, auth: { username: 'oauth2', - password: this.token, + password: this.config.token, }, logger, }); - return { remoteUrl }; + const catalogInfoUrl = remoteUrl.replace( + /\.git$/, + '/-/blob/master/catalog-info.yaml', + ); + return { remoteUrl, catalogInfoUrl }; } - private async createRemote( - values: RequiredTemplateValues & Record, - ) { - const [owner, name] = values.storePath.split('/'); + private async createRemote(opts: { name: string; owner: string }) { + const { owner, name } = opts; - let targetNamespace = ((await this.client.Namespaces.show(owner)) as { + // TODO(blam): this needs cleaning up to be nicer. The amount of brackets is too damn high! + // Shouldn't have to cast things now + let targetNamespace = ((await this.config.client.Namespaces.show( + owner, + )) as { id: number; }).id; + if (!targetNamespace) { - targetNamespace = ((await this.client.Users.current()) as { id: number }) - .id; + targetNamespace = ((await this.config.client.Users.current()) as { + id: number; + }).id; } - const project = (await this.client.Projects.create({ + const project = (await this.config.client.Projects.create({ namespace_id: targetNamespace, name: name, + visibility: this.config.repoVisibility, })) as { http_url_to_repo: string }; return project?.http_url_to_repo; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts index 796e48b8f8..ffefb88cf0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts @@ -39,7 +39,7 @@ export async function initRepoAndPush({ dir, }); - const paths = await globby(['./**', './**/.*'], { + const paths = await globby(['./**', './**/.*', '!.git'], { cwd: dir, gitignore: true, dot: true, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts index b37baa3246..e55aa0919b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/index.ts @@ -13,8 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './publishers'; -export * from './github'; -export * from './gitlab'; -export * from './azure'; -export * from './types'; +export { AzurePublisher } from './azure'; +export { BitbucketPublisher } from './bitbucket'; +export { GithubPublisher } from './github'; +export type { RepoVisibilityOptions } from './github'; +export { GitlabPublisher } from './gitlab'; +export { Publishers } from './publishers'; +export type { + PublisherBase, + PublisherBuilder, + PublisherOptions, + PublisherResult, +} from './types'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts index b8181b0134..87bcd1d003 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.test.ts @@ -14,120 +14,112 @@ * limitations under the License. */ import { Publishers } from './publishers'; -import { - LOCATION_ANNOTATION, - TemplateEntityV1alpha1, -} from '@backstage/catalog-model'; import { GithubPublisher } from './github'; -import { Octokit } from '@octokit/rest'; +import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { AzurePublisher } from './azure'; +import { GitlabPublisher } from './gitlab'; +import { BitbucketPublisher } from './bitbucket'; jest.mock('@octokit/rest'); +jest.mock('azure-devops-node-api'); describe('Publishers', () => { - const mockTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; + const logger = getVoidLogger(); it('should throw an error when the publisher for the source location is not registered', () => { const publishers = new Publishers(); - expect(() => publishers.get(mockTemplate)).toThrow( + expect(() => publishers.get('https://github.com/org/repo')).toThrow( expect.objectContaining({ - message: 'No publisher registered for type: "github"', + message: + 'Unable to find a publisher for URL: https://github.com/org/repo. Please make sure to register this host under an integration in app-config', }), ); }); - it('should return the correct preparer when the source matches', () => { - const publishers = new Publishers(); - const publisher = new GithubPublisher({ - client: new Octokit(), - token: 'fake', - repoVisibility: 'public', - }); - publishers.register('github', publisher); - - expect(publishers.get(mockTemplate)).toBe(publisher); - }); - - it('should throw an error if the metadata tag does not exist in the entity', () => { - const brokenTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: {}, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: '.', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, + it('should return the correct preparer when the source matches for github', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + github: [{ host: 'github.com', token: 'blob' }], }, - }, - }; - - const publishers = new Publishers(); - - expect(() => publishers.get(brokenTemplate)).toThrow( - expect.objectContaining({ - message: expect.stringContaining('No location annotation provided'), }), + { + logger, + }, ); + + expect(publishers.get('https://github.com/org/repo')).toBeInstanceOf( + GithubPublisher, + ); + }); + + it('should return the correct preparer when the source matches for azure', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + azure: [{ host: 'dev.azure.com', token: 'blob' }], + }, + }), + { + logger, + }, + ); + + expect( + publishers.get('https://dev.azure.com/org/project/_git/repo'), + ).toBeInstanceOf(AzurePublisher); + }); + + it('should return the correct preparer when the source matches for bitbucket', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + bitbucket: [{ host: 'bitbucket.com', token: 'blob' }], + }, + }), + { + logger, + }, + ); + expect(publishers.get('https://bitbucket.org/owner/repo')).toBeInstanceOf( + BitbucketPublisher, + ); + }); + + it('should return the correct preparer when the source matches for gitlab', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + gitlab: [{ host: 'gitlab.com', token: 'blob' }], + }, + }), + { + logger, + }, + ); + expect(publishers.get('https://gitlab.com/owner/repo')).toBeInstanceOf( + GitlabPublisher, + ); + }); + + it('should respect registrations for custom URLs for providers using the integrations config', async () => { + const publishers = await Publishers.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { host: 'my.special.github.enterprise.thing', token: 'lolghe' }, + ], + }, + }), + { + logger, + }, + ); + + expect( + publishers.get('https://my.special.github.enterprise.thing/org/repo'), + ).toBeInstanceOf(GithubPublisher); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index 75243aa610..30201e6dca 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -14,184 +14,161 @@ * limitations under the License. */ -import { Logger } from 'winston'; -import { Octokit } from '@octokit/rest'; -import { Gitlab } from '@gitbeaker/node'; -import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; import { Config } from '@backstage/config'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { - DeprecatedLocationTypeDetector, - makeDeprecatedLocationTypeDetector, - parseLocationAnnotation, -} from '../helpers'; import { PublisherBase, PublisherBuilder } from './types'; -import { RemoteProtocol } from '../types'; -import { GithubPublisher, RepoVisibilityOptions } from './github'; -import { GitlabPublisher } from './gitlab'; +import { + GithubPublisher, + RepoVisibilityOptions as GithubRepoVisibilityOptions, +} from './github'; +import { + GitlabPublisher, + RepoVisibilityOptions as GitlabRepoVisibilityOptions, +} from './gitlab'; import { AzurePublisher } from './azure'; -import { BitbucketPublisher } from './bitbucket'; +import { + BitbucketPublisher, + RepoVisibilityOptions as BitbucketRepoVisibilityOptions, +} from './bitbucket'; +import { Logger } from 'winston'; +import { ScmIntegrations } from '@backstage/integration'; export class Publishers implements PublisherBuilder { - private publisherMap = new Map(); + private publisherMap = new Map(); - constructor(private readonly typeDetector?: DeprecatedLocationTypeDetector) {} - - register(protocol: RemoteProtocol, publisher: PublisherBase) { - this.publisherMap.set(protocol, publisher); + register(host: string, preparer: PublisherBase | undefined) { + this.publisherMap.set(host, preparer); } - get(template: TemplateEntityV1alpha1): PublisherBase { - const { protocol, location } = parseLocationAnnotation(template); - const publisher = this.publisherMap.get(protocol); - - if (!publisher) { - if ((protocol as string) === 'url') { - const type = this.typeDetector?.(location); - const detected = type && this.publisherMap.get(type as RemoteProtocol); - if (detected) { - return detected; - } - if (type) { - throw new Error( - `No publisher configuration available for type '${type}' with url "${location}". ` + - "Make sure you've added appropriate configuration in the 'scaffolder' configuration section", - ); - } else { - throw new Error( - `Failed to detect publisher type. Unable to determine integration type for location "${location}". ` + - "Please add appropriate configuration to the 'integrations' configuration section", - ); - } - } - throw new Error(`No publisher registered for type: "${protocol}"`); + get(url: string): PublisherBase { + const preparer = this.publisherMap.get(new URL(url).host); + if (!preparer) { + throw new Error( + `Unable to find a publisher for URL: ${url}. Please make sure to register this host under an integration in app-config`, + ); } - - return publisher; + return preparer; } static async fromConfig( config: Config, { logger }: { logger: Logger }, ): Promise { - const typeDetector = makeDeprecatedLocationTypeDetector(config); - const publishers = new Publishers(typeDetector); + const publishers = new Publishers(); - const githubConfig = config.getOptionalConfig('scaffolder.github'); - if (githubConfig) { - try { - const repoVisibility = githubConfig.getString( - 'visibility', - ) as RepoVisibilityOptions; + const scm = ScmIntegrations.fromConfig(config); - const githubToken = githubConfig.getString('token'); - const githubHost = - githubConfig.getOptionalString('host') ?? 'https://api.github.com'; - const githubClient = new Octokit({ - auth: githubToken, - baseUrl: githubHost, - }); - const githubPublisher = new GithubPublisher({ - client: githubClient, - token: githubToken, + const deprecationWarning = (name: string) => { + logger.warn( + `'Specifying credentials for ${name} in the Scaffolder configuration is deprecated. This will cause errors in a future release. Please migrate to using integrations config and specifying tokens under hostnames'`, + ); + }; + + for (const integration of scm.azure.list()) { + const publisher = await AzurePublisher.fromConfig(integration.config); + if (publisher) { + publishers.register(integration.config.host, publisher); + } else { + deprecationWarning('Azure'); + + publishers.register( + integration.config.host, + await AzurePublisher.fromConfig({ + token: config.getOptionalString('scaffolder.azure.token'), + host: integration.config.host, + }), + ); + } + } + + for (const integration of scm.github.list()) { + const repoVisibility = (config.getOptionalString( + 'scaffolder.github.visibility', + ) ?? 'public') as GithubRepoVisibilityOptions; + + const publisher = await GithubPublisher.fromConfig(integration.config, { + repoVisibility, + }); + if (publisher) { + publishers.register(integration.config.host, publisher); + } else { + deprecationWarning('GitHub'); + + publishers.register( + integration.config.host, + await GithubPublisher.fromConfig( + { + token: config.getOptionalString('scaffolder.github.token') ?? '', + host: integration.config.host, + }, + { repoVisibility }, + ), + ); + } + } + + for (const integration of scm.gitlab.list()) { + const repoVisibility = (config.getOptionalString( + 'scaffolder.gitlab.visibility', + ) ?? 'public') as GitlabRepoVisibilityOptions; + + const publisher = await GitlabPublisher.fromConfig(integration.config, { + repoVisibility, + }); + + if (publisher) { + publishers.register(integration.config.host, publisher); + } else { + deprecationWarning('Gitlab'); + + publishers.register( + integration.config.host, + await GitlabPublisher.fromConfig( + { + token: config.getOptionalString('scaffolder.gitlab.token') ?? '', + host: integration.config.host, + }, + { repoVisibility }, + ), + ); + } + } + + for (const integration of scm.bitbucket.list()) { + const repoVisibility = (config.getOptionalString( + 'scaffolder.bitbucket.visibility', + ) ?? 'public') as BitbucketRepoVisibilityOptions; + + const publisher = await BitbucketPublisher.fromConfig( + integration.config, + { 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}`, - ); - } + if (publisher) { + publishers.register(integration.config.host, publisher); + } else { + deprecationWarning('Bitbucket'); - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, + publishers.register( + integration.config.host, + await BitbucketPublisher.fromConfig( + { + token: + config.getOptionalString('scaffolder.bitbucket.token') ?? '', + username: + config.getOptionalString('scaffolder.bitbucket.username') ?? '', + appPassword: + config.getOptionalString('scaffolder.bitbucket.appPassword') ?? + '', + host: integration.config.host, + }, + { repoVisibility }, + ), ); } } - const gitLabConfig = config.getOptionalConfig('scaffolder.gitlab'); - if (gitLabConfig) { - try { - const gitLabToken = gitLabConfig.getConfig('api').getString('token'); - const gitLabClient = new Gitlab({ - host: gitLabConfig.getConfig('api').getOptionalString('baseUrl'), - token: gitLabToken, - }); - const gitLabPublisher = new GitlabPublisher(gitLabClient, gitLabToken); - publishers.register('gitlab', gitLabPublisher); - publishers.register('gitlab/api', gitLabPublisher); - } catch (e) { - const providerName = 'gitlab'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } - - const azureConfig = config.getOptionalConfig('scaffolder.azure'); - if (azureConfig) { - try { - const baseUrl = azureConfig.getString('baseUrl'); - const azureToken = azureConfig.getConfig('api').getString('token'); - - const authHandler = getPersonalAccessTokenHandler(azureToken); - const webApi = new WebApi(baseUrl, authHandler); - const azureClient = await webApi.getGitApi(); - - const azurePublisher = new AzurePublisher(azureClient, azureToken); - publishers.register('azure/api', azurePublisher); - } catch (e) { - const providerName = 'azure'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } - - const bitbucketConfig = config.getOptionalConfig( - 'scaffolder.bitbucket.api', - ); - if (bitbucketConfig) { - try { - const baseUrl = bitbucketConfig.getString('host'); - const bitbucketUsername = bitbucketConfig.getString('username'); - const bitbucketToken = bitbucketConfig.getString('token'); - - const bitbucketPublisher = new BitbucketPublisher( - baseUrl, - bitbucketUsername, - bitbucketToken, - ); - publishers.register('bitbucket', bitbucketPublisher); - } catch (e) { - const providerName = 'bitbucket'; - if (process.env.NODE_ENV !== 'development') { - throw new Error( - `Failed to initialize ${providerName} scaffolding provider, ${e.message}`, - ); - } - - logger.warn( - `Skipping ${providerName} scaffolding provider, ${e.message}`, - ); - } - } return publishers; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index bc6b63cc57..3ae92b8b7d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts @@ -13,10 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { RequiredTemplateValues } from '../templater'; -import { JsonValue } from '@backstage/config'; -import { RemoteProtocol } from '../types'; +import { TemplaterValues } from '../templater'; import { Logger } from 'winston'; /** @@ -34,9 +31,9 @@ export type PublisherBase = { }; export type PublisherOptions = { - values: RequiredTemplateValues & Record; + values: TemplaterValues; + workspacePath: string; logger: Logger; - directory: string; }; export type PublisherResult = { @@ -45,6 +42,6 @@ export type PublisherResult = { }; export type PublisherBuilder = { - register(protocol: RemoteProtocol, publisher: PublisherBase): void; - get(template: TemplateEntityV1alpha1): PublisherBase; + register(host: string, publisher: PublisherBase): void; + get(storePath: string): PublisherBase; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index 568c95a073..77368fc1ab 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -13,107 +13,135 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('./helpers', () => ({ - runDockerContainer: jest.fn(), - runCommand: jest.fn(), -})); -jest.mock('command-exists-promise', () => jest.fn()); + +const runDockerContainer = jest.fn(); +const runCommand = jest.fn(); +const commandExists = jest.fn(); + +jest.mock('./helpers', () => ({ runDockerContainer, runCommand })); +jest.mock('command-exists-promise', () => commandExists); +jest.mock('fs-extra'); import { CookieCutter } from './cookiecutter'; import fs from 'fs-extra'; -import os from 'os'; import path from 'path'; -import { RunDockerContainerOptions, RunCommandOptions } from './helpers'; import { PassThrough } from 'stream'; import Docker from 'dockerode'; - -const commandExists = require('command-exists-promise'); +import parseGitUrl from 'git-url-parse'; describe('CookieCutter Templater', () => { - const cookie = new CookieCutter(); const mockDocker = {} as Docker; - const { - runDockerContainer, - }: { - runDockerContainer: jest.Mock; - } = require('./helpers'); - jest - .spyOn(fs, 'readdir') - .mockImplementation(() => Promise.resolve(['newthing'])); - - beforeEach(async () => { + beforeEach(() => { jest.clearAllMocks(); }); - const mkTemp = async () => { - const tempDir = os.tmpdir(); - return await fs.promises.mkdtemp(path.join(tempDir, 'temp')); - }; - it('should write a cookiecutter.json file with the values from the entity', async () => { - const tempdir = await mkTemp(); - const values = { owner: 'blobby', - storePath: 'backstage/end-repo', + storePath: 'https://github.com/org/repo', description: 'description', component_id: 'newthing', + destination: { + git: parseGitUrl('https://github.com/org/repo'), + }, }; - await cookie.run({ directory: tempdir, values, dockerClient: mockDocker }); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); - const cookieCutterJson = await fs.readJSON(`${tempdir}/cookiecutter.json`); + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }); - expect(cookieCutterJson).toEqual(expect.objectContaining(values)); + expect(fs.ensureDir).toBeCalledWith(path.join('tempdir', 'intermediate')); + expect(fs.writeJson).toBeCalledWith( + path.join('tempdir', 'template', 'cookiecutter.json'), + expect.objectContaining(values), + ); }); it('should merge any value that is in the cookiecutter.json path already', async () => { - const tempdir = await mkTemp(); const existingJson = { _copy_without_render: ['./github/workflows/*'], }; - await fs.writeJSON(`${tempdir}/cookiecutter.json`, existingJson); + jest + .spyOn(fs, 'readJSON') + .mockImplementationOnce(() => Promise.resolve(existingJson)); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); const values = { owner: 'blobby', - storePath: 'backstage/end-repo', + storePath: 'https://github.com/org/repo', component_id: 'something', + destination: { + git: parseGitUrl('https://github.com/org/repo'), + }, }; - await cookie.run({ directory: tempdir, values, dockerClient: mockDocker }); + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }); - const cookieCutterJson = await fs.readJSON(`${tempdir}/cookiecutter.json`); - - expect(cookieCutterJson).toEqual({ ...existingJson, ...values }); + expect(fs.writeJSON).toBeCalledWith( + path.join('tempdir', 'template', 'cookiecutter.json'), + { + ...existingJson, + ...values, + destination: { + git: expect.objectContaining({ organization: 'org', name: 'repo' }), + }, + }, + ); }); it('should throw an error if the cookiecutter json is malformed and not missing', async () => { - const tempdir = await mkTemp(); - - await fs.writeFile(`${tempdir}/cookiecutter.json`, "{'"); + jest.spyOn(fs, 'readJSON').mockImplementationOnce(() => { + throw new Error('BAM'); + }); const values = { owner: 'blobby', - storePath: 'backstage/end-repo', + storePath: 'https://github.com/org/repo', + destination: { + git: parseGitUrl('https://github.com/org/repo'), + }, }; + const templater = new CookieCutter(); await expect( - cookie.run({ directory: tempdir, values, dockerClient: mockDocker }), - ).rejects.toThrow(/Unexpected token ' in JSON at position 1/); + templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }), + ).rejects.toThrow('BAM'); }); it('should run the correct docker container with the correct bindings for the volumes', async () => { - const tempdir = await mkTemp(); - const values = { owner: 'blobby', - storePath: 'backstage/end-repo', + storePath: 'https://github.com/org/repo', component_id: 'newthing', + destination: { + git: parseGitUrl('https://github.com/org/repo'), + }, }; - await cookie.run({ directory: tempdir, values, dockerClient: mockDocker }); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }); expect(runDockerContainer).toHaveBeenCalledWith({ imageName: 'spotify/backstage-cookiecutter', @@ -125,44 +153,30 @@ describe('CookieCutter Templater', () => { '/template', '--verbose', ], - templateDir: tempdir, - resultDir: expect.stringContaining(`${tempdir}-result`), + templateDir: path.join('tempdir', 'template'), + resultDir: path.join('tempdir', 'intermediate'), logStream: undefined, dockerClient: mockDocker, }); }); - it('should return the result path to the end templated folder', async () => { - const tempdir = await mkTemp(); - - const values = { - owner: 'blobby', - storePath: 'backstage/end-repo', - component_id: 'newthing', - }; - - const { resultDir } = await cookie.run({ - directory: tempdir, - values, - dockerClient: mockDocker, - }); - - expect(resultDir.startsWith(`${tempdir}-result`)).toBeTruthy(); - }); - it('should pass through the streamer to the run docker helper', async () => { const stream = new PassThrough(); - const tempdir = await mkTemp(); - const values = { owner: 'blobby', - storePath: 'backstage/end-repo', + storePath: 'https://github.com/org/repo', component_id: 'newthing', + destination: { + git: parseGitUrl('https://github.com/org/repo'), + }, }; - await cookie.run({ - directory: tempdir, + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', values, logStream: stream, dockerClient: mockDocker, @@ -178,37 +192,32 @@ describe('CookieCutter Templater', () => { '/template', '--verbose', ], - templateDir: tempdir, - resultDir: expect.stringContaining(`${tempdir}-result`), + templateDir: path.join('tempdir', 'template'), + resultDir: path.join('tempdir', 'intermediate'), logStream: stream, dockerClient: mockDocker, }); }); describe('when cookiecutter is available', () => { - beforeAll(() => { - commandExists.mockImplementation(() => () => true); - }); - it('use the binary', async () => { - const { - runCommand, - }: { - runCommand: jest.Mock; - } = require('./helpers'); - const stream = new PassThrough(); - const tempdir = await mkTemp(); - const values = { owner: 'blobby', - storePath: 'backstage/end-repo', + storePath: 'https://github.com/org/repo', component_id: 'newthing', + destination: { + git: parseGitUrl('https://github.com/org/repo'), + }, }; - await cookie.run({ - directory: tempdir, + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + commandExists.mockImplementationOnce(() => () => true); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', values, logStream: stream, dockerClient: mockDocker, @@ -219,8 +228,8 @@ describe('CookieCutter Templater', () => { args: expect.arrayContaining([ '--no-input', '-o', - tempdir, - expect.stringContaining(`${tempdir}-result`), + path.join('tempdir', 'intermediate'), + path.join('tempdir', 'template'), '--verbose', ]), logStream: stream, @@ -229,26 +238,28 @@ describe('CookieCutter Templater', () => { }); describe('when nothing was generated', () => { - beforeEach(() => { - jest.spyOn(fs, 'readdir').mockImplementation(() => Promise.resolve([])); - }); - it('throws an error', async () => { const stream = new PassThrough(); - const tempdir = await mkTemp(); + jest + .spyOn(fs, 'readdir') + .mockImplementationOnce(() => Promise.resolve([])); - return expect( - cookie.run({ - directory: tempdir, + const templater = new CookieCutter(); + await expect( + templater.run({ + workspacePath: 'tempdir', values: { owner: 'blobby', - storePath: 'backstage/end-repo', + storePath: 'https://github.com/org/repo', + destination: { + git: parseGitUrl('https://github.com/org/repo'), + }, }, logStream: stream, dockerClient: mockDocker, }), - ).rejects.toThrow(/Cookie Cutter did not generate anything/); + ).rejects.toThrow(/No data generated by cookiecutter/); }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index 7be8730bd9..9568af2a15 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -18,7 +18,6 @@ import { JsonValue } from '@backstage/config'; import { runDockerContainer, runCommand } from './helpers'; import { TemplaterBase, TemplaterRunOptions } from '.'; import path from 'path'; -import { TemplaterRunResult } from './types'; const commandExists = require('command-exists-promise'); @@ -27,7 +26,7 @@ export class CookieCutter implements TemplaterBase { directory: string, ): Promise> { try { - return await fs.readJSON(`${directory}/cookiecutter.json`); + return await fs.readJSON(path.join(directory, 'cookiecutter.json')); } catch (ex) { if (ex.code !== 'ENOENT') { throw ex; @@ -37,28 +36,33 @@ export class CookieCutter implements TemplaterBase { } } - public async run(options: TemplaterRunOptions): Promise { + public async run({ + workspacePath, + dockerClient, + values, + logStream, + }: TemplaterRunOptions): Promise { + const templateDir = path.join(workspacePath, 'template'); + const intermediateDir = path.join(workspacePath, 'intermediate'); + await fs.ensureDir(intermediateDir); + const resultDir = path.join(workspacePath, 'result'); + // First lets grab the default cookiecutter.json file - const cookieCutterJson = await this.fetchTemplateCookieCutter( - options.directory, - ); + const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir); const cookieInfo = { ...cookieCutterJson, - ...options.values, + ...values, }; - await fs.writeJSON(`${options.directory}/cookiecutter.json`, cookieInfo); - - const templateDir = options.directory; - const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); + await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo); const cookieCutterInstalled = await commandExists('cookiecutter'); if (cookieCutterInstalled) { await runCommand({ command: 'cookiecutter', - args: ['--no-input', '-o', resultDir, templateDir, '--verbose'], - logStream: options.logStream, + args: ['--no-input', '-o', intermediateDir, templateDir, '--verbose'], + logStream, }); } else { await runDockerContainer({ @@ -72,22 +76,20 @@ export class CookieCutter implements TemplaterBase { '--verbose', ], templateDir, - resultDir, - logStream: options.logStream, - dockerClient: options.dockerClient, + resultDir: intermediateDir, + logStream, + dockerClient, }); } - // if cookiecutter was successful, resultDir will contain + // if cookiecutter was successful, intermediateDir will contain // exactly one directory. - const [generated] = await fs.readdir(resultDir); + const [generated] = await fs.readdir(intermediateDir); if (generated === undefined) { - throw new Error('Cookie Cutter did not generate anything'); + throw new Error('No data generated by cookiecutter'); } - return { - resultDir: path.resolve(resultDir, generated), - }; + await fs.move(path.join(intermediateDir, generated), resultDir); } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts index b30e86d903..5e957e0504 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts @@ -17,25 +17,30 @@ import fs from 'fs-extra'; import { runDockerContainer } from '../helpers'; import { TemplaterBase, TemplaterRunOptions } from '..'; import path from 'path'; -import { TemplaterRunResult } from '../types'; import * as yaml from 'yaml'; -import { resolvePackagePath } from '@backstage/backend-common'; // TODO(blam): Replace with the universal import from github-actions after a release // As it will break the E2E without it const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug'; export class CreateReactAppTemplater implements TemplaterBase { - public async run(options: TemplaterRunOptions): Promise { + public async run({ + workspacePath, + values, + logStream, + dockerClient, + }: TemplaterRunOptions): Promise { const { component_id: componentName, use_typescript: withTypescript, use_github_actions: withGithubActions, description, owner, - } = options.values; + } = values; + const intermediateDir = path.join(workspacePath, 'template'); + await fs.ensureDir(intermediateDir); - const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); + const resultDir = path.join(workspacePath, 'result'); await runDockerContainer({ imageName: 'node:lts-alpine', @@ -44,33 +49,80 @@ export class CreateReactAppTemplater implements TemplaterBase { componentName as string, withTypescript ? ' --template typescript' : '', ], - templateDir: options.directory, - resultDir, - logStream: options.logStream, - dockerClient: options.dockerClient, + templateDir: intermediateDir, + resultDir: intermediateDir, + logStream: logStream, + dockerClient: dockerClient, createOptions: { Entrypoint: ['npx'], WorkingDir: '/result', }, }); - const extraAnnotations: Record = {}; - const finalDir = path.resolve( - resultDir, - options.values.component_id as string, - ); + // if cookiecutter was successful, intermediateDir will contain + // exactly one directory. + const [generated] = await fs.readdir(intermediateDir); + if (generated === undefined) { + throw new Error('No data generated by cookiecutter'); + } + + await fs.move(path.join(intermediateDir, generated), resultDir); + + const extraAnnotations: Record = {}; if (withGithubActions) { - await fs.promises.mkdir(`${finalDir}/.github`); - await fs.promises.mkdir(`${finalDir}/.github/workflows`); - await fs.promises.copyFile( - `${resolvePackagePath( - '@backstage/plugin-scaffolder-backend', - )}/templates/.github/workflows/main.yml`, - `${finalDir}/.github/workflows/main.yml`, + await fs.mkdir(`${resultDir}/.github`); + await fs.mkdir(`${resultDir}/.github/workflows`); + const githubActionsYaml = ` +name: CRA Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x] + + steps: + - name: checkout code + uses: actions/checkout@v1 + - name: get yarn cache + id: yarn-cache + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v2 + with: + path: \${{ steps.yarn-cache.outputs.dir }} + key: \${{ runner.os }}-yarn-\${{ hashFiles('**/yarn.lock') }} + restore-keys: | + \${{ runner.os }}-yarn- + - name: use node.js \${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: \${{ matrix.node-version }} + - name: yarn install, build, and test + working-directory: . + run: | + yarn install + yarn build + yarn test + env: + CI: true + `; + await fs.writeFile( + `${resultDir}/.github/workflows/main.yml`, + githubActionsYaml, ); - extraAnnotations[GITHUB_ACTIONS_ANNOTATION] = options.values.storePath; + extraAnnotations[ + GITHUB_ACTIONS_ANNOTATION + ] = `${values?.destination?.git?.owner}/${values?.destination?.git?.name}`; } const componentInfo = { @@ -90,13 +142,9 @@ export class CreateReactAppTemplater implements TemplaterBase { }, }; - await fs.promises.writeFile( - `${finalDir}/catalog-info.yaml`, + await fs.writeFile( + `${resultDir}/catalog-info.yaml`, yaml.stringify(componentInfo), ); - - return { - resultDir: finalDir, - }; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml deleted file mode 100644 index 65cae9abbb..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: CRA Build - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [12.x] - - steps: - - name: checkout code - uses: actions/checkout@v1 - - name: get yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: use node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: yarn install, build, and test - working-directory: . - run: | - yarn install - yarn build - yarn test - env: - CI: true diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts index 9ef20b7740..209210c075 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts @@ -14,52 +14,13 @@ * limitations under the License. */ import { Templaters } from '.'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { CookieCutter } from './cookiecutter'; describe('Templaters', () => { - const mockTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml', - }, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - templater: 'cookiecutter', - path: '.', - type: 'website', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; it('should throw an error when the templater is not registered', () => { const templaters = new Templaters(); - expect(() => templaters.get(mockTemplate)).toThrow( + expect(() => templaters.get('cookiecutter')).toThrow( expect.objectContaining({ message: 'No templater registered for template: "cookiecutter"', }), @@ -71,55 +32,6 @@ describe('Templaters', () => { templaters.register('cookiecutter', templater); - expect(templaters.get(mockTemplate)).toBe(templater); - }); - - it('should throw an error if the templater does not exist in the entity', () => { - const brokenTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: {}, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - type: 'website', - path: '.', - templater: '', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; - - const templaters = new Templaters(); - - expect(() => templaters.get(brokenTemplate)).toThrow( - expect.objectContaining({ - name: 'InputError', - message: expect.stringContaining( - 'Template does not have a required templating key', - ), - }), - ); + expect(templaters.get('cookiecutter')).toBe(templater); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts index 549b898af8..9535d28837 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts @@ -20,26 +20,20 @@ import { TemplaterBuilder, } from './types'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { getTemplaterKey } from './helpers'; - export class Templaters implements TemplaterBuilder { - private preparerMap = new Map(); + private templaterMap = new Map(); register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase) { - this.preparerMap.set(templaterKey, templater); + this.templaterMap.set(templaterKey, templater); } - get(template: TemplateEntityV1alpha1): TemplaterBase { - const templaterKey = getTemplaterKey(template); - const preparer = this.preparerMap.get(templaterKey); + get(templaterId: string): TemplaterBase { + const templater = this.templaterMap.get(templaterId); - if (!preparer) { - throw new Error( - `No templater registered for template: "${templaterKey}"`, - ); + if (!templater) { + throw new Error(`No templater registered for template: "${templaterId}"`); } - return preparer; + return templater; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts index 5b01960612..fa14788d22 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts @@ -15,8 +15,7 @@ */ import type { Writable } from 'stream'; import Docker from 'dockerode'; -import { JsonValue } from '@backstage/config'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import gitUrlParse from 'git-url-parse'; /** * Currently the required template values. The owner @@ -25,8 +24,13 @@ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; export type RequiredTemplateValues = { owner: string; storePath: string; + destination?: { + git?: gitUrlParse.GitUrl; + }; }; +export type TemplaterValues = RequiredTemplateValues & Record; + /** * The returned directory from the templater which is ready * to pass to the next stage of the scaffolder which is publishing @@ -41,15 +45,15 @@ export type TemplaterRunResult = { * client to run any templater on top of your directory. */ export type TemplaterRunOptions = { - directory: string; - values: RequiredTemplateValues & Record; + workspacePath: string; + values: TemplaterValues; logStream?: Writable; dockerClient: Docker; }; export type TemplaterBase = { // runs the templating with the values and returns the directory to push the VCS - run(opts: TemplaterRunOptions): Promise; + run(opts: TemplaterRunOptions): Promise; }; export type TemplaterConfig = { @@ -66,5 +70,5 @@ export type SupportedTemplatingKey = 'cookiecutter' | string; */ export type TemplaterBuilder = { register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void; - get(template: TemplateEntityV1alpha1): TemplaterBase; + get(templater: string): TemplaterBase; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts new file mode 100644 index 0000000000..5717f3c8c1 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts @@ -0,0 +1,272 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonObject } from '@backstage/config'; +import { + ConflictError, + NotFoundError, + resolvePackagePath, +} from '@backstage/backend-common'; +import Knex from 'knex'; +import { v4 as uuid } from 'uuid'; +import { + DbTaskEventRow, + DbTaskRow, + Status, + TaskEventType, + TaskSpec, + TaskStore, + TaskStoreEmitOptions, + TaskStoreGetEventsOptions, +} from './types'; + +const migrationsDir = resolvePackagePath( + '@backstage/plugin-scaffolder-backend', + 'migrations', +); + +export type RawDbTaskRow = { + id: string; + spec: string; + status: Status; + last_heartbeat_at?: string; + created_at: string; +}; + +export type RawDbTaskEventRow = { + id: number; + task_id: string; + body: string; + event_type: TaskEventType; + created_at: string; +}; + +export class DatabaseTaskStore implements TaskStore { + static async create(knex: Knex): Promise { + await knex.migrate.latest({ + directory: migrationsDir, + }); + return new DatabaseTaskStore(knex); + } + + constructor(private readonly db: Knex) {} + + async get(taskId: string): Promise { + const [result] = await this.db('tasks') + .where({ id: taskId }) + .select(); + if (!result) { + throw new NotFoundError(`No task with id '${taskId}' found`); + } + try { + const spec = JSON.parse(result.spec); + return { + id: result.id, + spec, + status: result.status, + lastHeartbeatAt: result.last_heartbeat_at, + createdAt: result.created_at, + }; + } catch (error) { + throw new Error(`Failed to parse spec of task '${taskId}', ${error}`); + } + } + + async createTask(spec: TaskSpec): Promise<{ taskId: string }> { + const taskId = uuid(); + await this.db('tasks').insert({ + id: taskId, + spec: JSON.stringify(spec), + status: 'open', + }); + return { taskId }; + } + + async claimTask(): Promise { + return this.db.transaction(async tx => { + const [task] = await tx('tasks') + .where({ + status: 'open', + }) + .limit(1) + .select(); + + if (!task) { + return undefined; + } + + const updateCount = await tx('tasks') + .where({ id: task.id, status: 'open' }) + .update({ + status: 'processing', + last_heartbeat_at: this.db.fn.now(), + }); + + if (updateCount < 1) { + return undefined; + } + + try { + const spec = JSON.parse(task.spec); + return { + id: task.id, + spec, + status: 'processing', + lastHeartbeatAt: task.last_heartbeat_at, + createdAt: task.created_at, + }; + } catch (error) { + throw new Error(`Failed to parse spec of task '${task.id}', ${error}`); + } + }); + } + + async heartbeatTask(taskId: string): Promise { + const updateCount = await this.db('tasks') + .where({ id: taskId, status: 'processing' }) + .update({ + last_heartbeat_at: this.db.fn.now(), + }); + if (updateCount === 0) { + throw new ConflictError(`No running task with taskId ${taskId} found`); + } + } + + async listStaleTasks({ + timeoutS, + }: { + timeoutS: number; + }): Promise<{ + tasks: { taskId: string }[]; + }> { + const rawRows = await this.db('tasks') + .where('status', 'processing') + .andWhere( + 'last_heartbeat_at', + '<=', + this.db.client.config.client === 'sqlite3' + ? this.db.raw(`datetime('now', ?)`, [`-${timeoutS} seconds`]) + : this.db.raw(`dateadd('second', ?, ?)`, [ + `-${timeoutS}`, + this.db.fn.now(), + ]), + ); + const tasks = rawRows.map(row => ({ + taskId: row.id, + })); + return { tasks }; + } + + async completeTask({ + taskId, + status, + eventBody, + }: { + taskId: string; + status: Status; + eventBody: JsonObject; + }): Promise { + let oldStatus: string; + if (status === 'failed' || status === 'completed') { + oldStatus = 'processing'; + } else { + throw new Error( + `Invalid status update of run '${taskId}' to status '${status}'`, + ); + } + await this.db.transaction(async tx => { + const [task] = await tx('tasks') + .where({ + id: taskId, + }) + .limit(1) + .select(); + + if (!task) { + throw new Error(`No task with taskId ${taskId} found`); + } + if (task.status !== oldStatus) { + throw new ConflictError( + `Refusing to update status of run '${taskId}' to status '${status}' ` + + `as it is currently '${task.status}', expected '${oldStatus}'`, + ); + } + const updateCount = await tx('tasks') + .where({ + id: taskId, + status: oldStatus, + }) + .update({ + status, + }); + if (updateCount !== 1) { + throw new ConflictError( + `Failed to update status to '${status}' for taskId ${taskId}`, + ); + } + + await tx('task_events').insert({ + task_id: taskId, + event_type: 'completion', + body: JSON.stringify(eventBody), + }); + }); + } + + async emitLogEvent({ taskId, body }: TaskStoreEmitOptions): Promise { + const serliazedBody = JSON.stringify(body); + await this.db('task_events').insert({ + task_id: taskId, + event_type: 'log', + body: serliazedBody, + }); + } + + async listEvents({ + taskId, + after, + }: TaskStoreGetEventsOptions): Promise<{ events: DbTaskEventRow[] }> { + const rawEvents = await this.db('task_events') + .where({ + task_id: taskId, + }) + .andWhere(builder => { + if (typeof after === 'number') { + builder.where('id', '>', after).orWhere('event_type', 'completion'); + } + }) + .orderBy('id') + .select(); + + const events = rawEvents.map(event => { + try { + const body = JSON.parse(event.body) as JsonObject; + return { + id: event.id, + taskId, + body, + type: event.event_type, + createdAt: event.created_at, + }; + } catch (error) { + throw new Error( + `Failed to parse event body from event taskId=${taskId} id=${event.id}, ${error}`, + ); + } + }); + return { events }; + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts new file mode 100644 index 0000000000..c45fa46335 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts @@ -0,0 +1,183 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, + SingleConnectionDatabaseManager, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { DatabaseTaskStore } from './DatabaseTaskStore'; +import { StorageTaskBroker, TaskAgent } from './StorageTaskBroker'; +import { TaskSpec, DbTaskEventRow } from './types'; + +async function createStore(): Promise { + const manager = SingleConnectionDatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('scaffolder'); + return await DatabaseTaskStore.create(await manager.getClient()); +} + +describe('StorageTaskBroker', () => { + let storage: DatabaseTaskStore; + + beforeAll(async () => { + storage = await createStore(); + }); + + const logger = getVoidLogger(); + it('should claim a dispatched work item', async () => { + const broker = new StorageTaskBroker(storage, logger); + await broker.dispatch({ steps: [] }); + await expect(broker.claim()).resolves.toEqual(expect.any(TaskAgent)); + }); + + it('should wait for a dispatched work item', async () => { + const broker = new StorageTaskBroker(storage, logger); + const promise = broker.claim(); + + await expect(Promise.race([promise, 'waiting'])).resolves.toBe('waiting'); + + await broker.dispatch({ steps: [] }); + await expect(promise).resolves.toEqual(expect.any(TaskAgent)); + }); + + it('should dispatch multiple items and claim them in order', async () => { + const broker = new StorageTaskBroker(storage, logger); + await broker.dispatch({ steps: [{ id: 'a' }] } as TaskSpec); + await broker.dispatch({ steps: [{ id: 'b' }] } as TaskSpec); + await broker.dispatch({ steps: [{ id: 'c' }] } as TaskSpec); + + const taskA = await broker.claim(); + const taskB = await broker.claim(); + const taskC = await broker.claim(); + await expect(taskA).toEqual(expect.any(TaskAgent)); + await expect(taskB).toEqual(expect.any(TaskAgent)); + await expect(taskC).toEqual(expect.any(TaskAgent)); + await expect(taskA.spec.steps[0].id).toBe('a'); + await expect(taskB.spec.steps[0].id).toBe('b'); + await expect(taskC.spec.steps[0].id).toBe('c'); + }); + + it('should complete a task', async () => { + const broker = new StorageTaskBroker(storage, logger); + const dispatchResult = await broker.dispatch({ steps: [] }); + const task = await broker.claim(); + await task.complete('completed'); + const taskRow = await storage.get(dispatchResult.taskId); + expect(taskRow.status).toBe('completed'); + }, 10000); + + it('should fail a task', async () => { + const broker = new StorageTaskBroker(storage, logger); + const dispatchResult = await broker.dispatch({ steps: [] }); + const task = await broker.claim(); + await task.complete('failed'); + const taskRow = await storage.get(dispatchResult.taskId); + expect(taskRow.status).toBe('failed'); + }); + + it('multiple brokers should be able to observe a single task', async () => { + const broker1 = new StorageTaskBroker(storage, logger); + const broker2 = new StorageTaskBroker(storage, logger); + + const { taskId } = await broker1.dispatch({ steps: [] }); + + const logPromise = new Promise(resolve => { + const observedEvents = new Array(); + + broker2.observe({ taskId, after: undefined }, (_err, { events }) => { + observedEvents.push(...events); + if (events.some(e => e.type === 'completion')) { + resolve(observedEvents); + } + }); + }); + const task = await broker1.claim(); + await task.emitLog('log 1'); + await task.emitLog('log 2'); + await task.emitLog('log 3'); + await task.complete('completed'); + + const logs = await logPromise; + expect(logs.map(l => l.body.message, logger)).toEqual([ + 'log 1', + 'log 2', + 'log 3', + 'Run completed with status: completed', + ]); + + const afterLogs = await new Promise(resolve => { + broker2.observe({ taskId, after: logs[1].id }, (_err, { events }) => + resolve(events.map(e => e.body.message as string)), + ); + }); + expect(afterLogs).toEqual([ + 'log 3', + 'Run completed with status: completed', + ]); + }); + + it('should heartbeat', async () => { + const broker = new StorageTaskBroker(storage, logger); + const { taskId } = await broker.dispatch({ steps: [] }); + const task = await broker.claim(); + + const initialTask = await storage.get(taskId); + + for (;;) { + const maybeTask = await storage.get(taskId); + if (maybeTask.lastHeartbeatAt !== initialTask.lastHeartbeatAt) { + break; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + await task.complete('completed'); + expect.assertions(0); + }); + + it('should be update the status to failed if heartbeat fails', async () => { + const broker = new StorageTaskBroker(storage, logger); + const { taskId } = await broker.dispatch({ steps: [] }); + const task = await broker.claim(); + + jest + .spyOn((task as any).storage, 'heartbeatTask') + .mockRejectedValue(new Error('nah m8')); + + const intervalId = setInterval(() => { + broker.vacuumTasks({ timeoutS: 2 }).catch(fail); + }, 500); + + for (;;) { + const maybeTask = await storage.get(taskId); + if (maybeTask.status === 'failed') { + break; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + + clearInterval(intervalId); + + expect(task.done).toBe(true); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts new file mode 100644 index 0000000000..48c0d5e5ba --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -0,0 +1,205 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { + CompletedTaskState, + Task, + TaskSpec, + TaskStore, + TaskBroker, + DispatchResult, + DbTaskEventRow, +} from './types'; + +export class TaskAgent implements Task { + private isDone = false; + + private heartbeatTimeoutId?: ReturnType; + + static create(state: TaskState, storage: TaskStore, logger: Logger) { + const agent = new TaskAgent(state, storage, logger); + agent.startTimeout(); + return agent; + } + + // Runs heartbeat internally + private constructor( + private readonly state: TaskState, + private readonly storage: TaskStore, + private readonly logger: Logger, + ) {} + + get spec() { + return this.state.spec; + } + + async getWorkspaceName() { + return this.state.taskId; + } + + get done() { + return this.isDone; + } + + async emitLog(message: string): Promise { + await this.storage.emitLogEvent({ + taskId: this.state.taskId, + body: { message }, + }); + } + + async complete(result: CompletedTaskState): Promise { + await this.storage.completeTask({ + taskId: this.state.taskId, + status: result === 'failed' ? 'failed' : 'completed', + eventBody: { message: `Run completed with status: ${result}` }, + }); + this.isDone = true; + if (this.heartbeatTimeoutId) { + clearTimeout(this.heartbeatTimeoutId); + } + } + + private startTimeout() { + this.heartbeatTimeoutId = setTimeout(async () => { + try { + await this.storage.heartbeatTask(this.state.taskId); + this.startTimeout(); + } catch (error) { + this.isDone = true; + + this.logger.error( + `Heartbeat for task ${this.state.taskId} failed`, + error, + ); + } + }, 1000); + } +} + +interface TaskState { + spec: TaskSpec; + taskId: string; +} + +function defer() { + let resolve = () => {}; + const promise = new Promise(_resolve => { + resolve = _resolve; + }); + return { promise, resolve }; +} + +export class StorageTaskBroker implements TaskBroker { + constructor( + private readonly storage: TaskStore, + private readonly logger: Logger, + ) {} + private deferredDispatch = defer(); + + async claim(): Promise { + for (;;) { + const pendingTask = await this.storage.claimTask(); + if (pendingTask) { + return TaskAgent.create( + { + taskId: pendingTask.id, + spec: pendingTask.spec, + }, + this.storage, + this.logger, + ); + } + + await this.waitForDispatch(); + } + } + + async dispatch(spec: TaskSpec): Promise { + const taskRow = await this.storage.createTask(spec); + this.signalDispatch(); + return { + taskId: taskRow.taskId, + }; + } + + observe( + options: { + taskId: string; + after: number | undefined; + }, + callback: ( + error: Error | undefined, + result: { events: DbTaskEventRow[] }, + ) => void, + ): () => void { + const { taskId } = options; + + let cancelled = false; + const unsubscribe = () => { + cancelled = true; + }; + + (async () => { + let after = options.after; + while (!cancelled) { + const result = await this.storage.listEvents({ taskId, after: after }); + const { events } = result; + if (events.length) { + after = events[events.length - 1].id; + try { + callback(undefined, result); + } catch (error) { + callback(error, { events: [] }); + } + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + } + })(); + + return unsubscribe; + } + + async vacuumTasks(timeoutS: { timeoutS: number }): Promise { + const { tasks } = await this.storage.listStaleTasks(timeoutS); + await Promise.all( + tasks.map(async task => { + try { + await this.storage.completeTask({ + taskId: task.taskId, + status: 'failed', + eventBody: { + message: + 'The task was cancelled because the task worker lost connection to the task broker', + }, + }); + } catch (error) { + this.logger.warn(`Failed to cancel task '${task.taskId}', ${error}`); + } + }), + ); + } + + private waitForDispatch() { + return this.deferredDispatch.promise; + } + + private signalDispatch() { + this.deferredDispatch.resolve(); + this.deferredDispatch = defer(); + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts new file mode 100644 index 0000000000..c62495ca4c --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PassThrough } from 'stream'; +import { Logger } from 'winston'; +import * as winston from 'winston'; +import { JsonValue } from '@backstage/config'; +import { TaskBroker, Task } from './types'; +import fs from 'fs-extra'; +import path from 'path'; +import { TemplateActionRegistry } from './TemplateConverter'; + +type Options = { + logger: Logger; + taskBroker: TaskBroker; + workingDirectory: string; + actionRegistry: TemplateActionRegistry; +}; + +export class TaskWorker { + constructor(private readonly options: Options) {} + + start() { + (async () => { + for (;;) { + const task = await this.options.taskBroker.claim(); + await this.runOneTask(task); + } + })(); + } + + async runOneTask(task: Task) { + try { + const { actionRegistry, logger } = this.options; + + const workspacePath = path.join( + this.options.workingDirectory, + await task.getWorkspaceName(), + ); + await fs.ensureDir(workspacePath); + + const taskLogger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.colorize(), + winston.format.timestamp(), + winston.format.simple(), + ), + defaultMeta: {}, + }); + + const stream = new PassThrough(); + stream.on('data', data => { + const message = data.toString().trim(); + if (message?.length > 1) task.emitLog(message); + }); + + taskLogger.add(new winston.transports.Stream({ stream })); + + // Give us some time to curl observe + task.emitLog('Task claimed, waiting ...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + task.emitLog(`Starting up work with ${task.spec.steps.length} steps`); + + const outputs: { [name: string]: JsonValue } = {}; + + for (const step of task.spec.steps) { + task.emitLog(`Beginning step ${step.name}`); + + const action = actionRegistry.get(step.action); + if (!action) { + throw new Error(`Action '${step.action}' does not exist`); + } + + // TODO: substitute any placeholders with output from previous steps + const parameters = step.parameters!; + + await action.handler({ + logger, + logStream: stream, + parameters, + workspacePath, + output(name: string, value: JsonValue) { + outputs[name] = value; + }, + }); + + task.emitLog(`Finished step ${step.name}`); + } + + await task.complete('completed'); + } catch (error) { + task.emitLog(String(error.stack)); + await task.complete('failed'); + } + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts new file mode 100644 index 0000000000..69788238cc --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolve as resolvePath } from 'path'; +import { JsonValue } from '@backstage/config'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { Logger } from 'winston'; +import type { Writable } from 'stream'; + +import { TaskSpec } from './types'; +import { ConflictError, NotFoundError } from '@backstage/backend-common'; +import { + getTemplaterKey, + joinGitUrlPath, + parseLocationAnnotation, + TemplaterValues, +} from '../stages'; + +export function templateEntityToSpec( + template: TemplateEntityV1alpha1, + values: TemplaterValues, +): TaskSpec { + const steps: TaskSpec['steps'] = []; + + const { protocol, location } = parseLocationAnnotation(template); + + let url: string; + if (protocol === 'file') { + const path = resolvePath(location, template.spec.path || '.'); + + url = `file://${path}`; + } else { + url = joinGitUrlPath(location, template.spec.path); + } + const templater = getTemplaterKey(template); + + steps.push({ + id: 'prepare', + name: 'Prepare', + action: 'legacy:prepare', + parameters: { + protocol, + url, + }, + }); + + steps.push({ + id: 'template', + name: 'Template', + action: 'legacy:template', + parameters: { + templater, + values, + }, + }); + + steps.push({ + id: 'publish', + name: 'Publishing', + action: 'legacy:publish', + parameters: { + values, + }, + }); + + return { steps }; +} + +type ActionContext = { + logger: Logger; + logStream: Writable; + + workspacePath: string; + parameters: { [name: string]: JsonValue }; + output(name: string, value: JsonValue): void; +}; + +type TemplateAction = { + id: string; + handler: (ctx: ActionContext) => Promise; +}; + +export class TemplateActionRegistry { + private readonly actions = new Map(); + + register(action: TemplateAction) { + if (this.actions.has(action.id)) { + throw new ConflictError( + `Template action with ID '${action.id}' has already been registered`, + ); + } + this.actions.set(action.id, action); + } + + get(actionId: string): TemplateAction { + const action = this.actions.get(actionId); + if (!action) { + throw new NotFoundError( + `Template action with ID '${actionId}' is not registered.`, + ); + } + return action; + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts new file mode 100644 index 0000000000..c85135426e --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { DatabaseTaskStore } from './DatabaseTaskStore'; +export { StorageTaskBroker } from './StorageTaskBroker'; +export { TaskWorker } from './TaskWorker'; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts new file mode 100644 index 0000000000..0c2592109b --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonValue, JsonObject } from '@backstage/config'; + +export type Status = + | 'open' + | 'processing' + | 'failed' + | 'cancelled' + | 'completed'; + +export type CompletedTaskState = 'failed' | 'completed'; + +export type DbTaskRow = { + id: string; + spec: TaskSpec; + status: Status; + createdAt: string; + lastHeartbeatAt?: string; +}; + +export type TaskEventType = 'completion' | 'log'; +export type DbTaskEventRow = { + id: number; + taskId: string; + body: JsonObject; + type: TaskEventType; + createdAt: string; +}; + +export type TaskSpec = { + steps: Array<{ + id: string; + name: string; + action: string; + parameters?: { [name: string]: JsonValue }; + }>; +}; + +export type DispatchResult = { + taskId: string; +}; + +export interface Task { + spec: TaskSpec; + done: boolean; + emitLog(message: string): Promise; + complete(result: CompletedTaskState): Promise; + getWorkspaceName(): Promise; +} + +export interface TaskBroker { + claim(): Promise; + dispatch(spec: TaskSpec): Promise; + vacuumTasks(timeoutS: { timeoutS: number }): Promise; + observe( + options: { + taskId: string; + after: number | undefined; + }, + callback: ( + error: Error | undefined, + result: { events: DbTaskEventRow[] }, + ) => void, + ): () => void; +} + +export type TaskStoreEmitOptions = { + taskId: string; + body: JsonObject; +}; + +export type TaskStoreGetEventsOptions = { + taskId: string; + after?: number | undefined; +}; +export interface TaskStore { + createTask(task: TaskSpec): Promise<{ taskId: string }>; + claimTask(): Promise; + completeTask(options: { + taskId: string; + status: Status; + eventBody: JsonObject; + }): Promise; + heartbeatTask(taskId: string): Promise; + listStaleTasks(options: { + timeoutS: number; + }): Promise<{ + tasks: { taskId: string }[]; + }>; + + emitLogEvent({ taskId, body }: TaskStoreEmitOptions): Promise; + listEvents({ + taskId, + after, + }: TaskStoreGetEventsOptions): Promise<{ events: DbTaskEventRow[] }>; +} diff --git a/plugins/scaffolder-backend/src/service/helpers.ts b/plugins/scaffolder-backend/src/service/helpers.ts new file mode 100644 index 0000000000..dd3d43c6d1 --- /dev/null +++ b/plugins/scaffolder-backend/src/service/helpers.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 os from 'os'; +import fs from 'fs-extra'; +import { Logger } from 'winston'; +import { Config } from '@backstage/config'; + +export async function getWorkingDirectory( + config: Config, + logger: Logger, +): Promise { + if (!config.has('backend.workingDirectory')) { + return os.tmpdir(); + } + + const workingDirectory = config.getString('backend.workingDirectory'); + try { + // Check if working directory exists and is writable + await fs.access(workingDirectory, fs.constants.F_OK | fs.constants.W_OK); + logger.info(`using working directory: ${workingDirectory}`); + } catch (err) { + logger.error( + `working directory ${workingDirectory} ${ + err.code === 'ENOENT' ? 'does not exist' : 'is not writable' + }`, + ); + throw err; + } + return workingDirectory; +} diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 4d610299eb..8a0fa6cba6 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -16,6 +16,7 @@ const mockAccess = jest.fn(); jest.doMock('fs-extra', () => ({ + access: mockAccess, promises: { access: mockAccess, }, @@ -23,9 +24,15 @@ jest.doMock('fs-extra', () => ({ F_OK: 0, W_OK: 1, }, + mkdir: jest.fn(), + remove: jest.fn(), })); -import { getVoidLogger } from '@backstage/backend-common'; +import { + SingleConnectionDatabaseManager, + PluginDatabaseManager, + getVoidLogger, +} from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; @@ -39,6 +46,19 @@ const generateEntityClient: any = (template: any) => ({ findTemplate: () => Promise.resolve(template), }); +function createDatabase(): PluginDatabaseManager { + return SingleConnectionDatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('scaffolder'); +} + describe('createRouter - working directory', () => { const mockPrepare = jest.fn(); const mockPreparers = new Preparers(); @@ -47,7 +67,7 @@ describe('createRouter - working directory', () => { const mockPreparer = { prepare: mockPrepare, }; - mockPreparers.register('azure/api', mockPreparer); + mockPreparers.register('dev.azure.com', mockPreparer); }); beforeEach(() => { @@ -65,7 +85,7 @@ describe('createRouter - working directory', () => { kind: 'Template', metadata: { annotations: { - 'backstage.io/managed-by-location': 'azure/api:dev.azure.com', + 'backstage.io/managed-by-location': 'url:https://dev.azure.com', }, }, spec: { @@ -76,7 +96,6 @@ describe('createRouter - working directory', () => { }; const mockedEntityClient = generateEntityClient(template); - it('should throw an error when working directory does not exist or is not writable', async () => { mockAccess.mockImplementation(() => { throw new Error('access error'); @@ -91,6 +110,7 @@ describe('createRouter - working directory', () => { config: new ConfigReader(workDirConfig('/path')), dockerClient: new Docker(), entityClient: mockedEntityClient, + database: createDatabase(), }), ).rejects.toThrow('access error'); }); @@ -104,17 +124,23 @@ describe('createRouter - working directory', () => { config: new ConfigReader(workDirConfig('/path')), dockerClient: new Docker(), entityClient: mockedEntityClient, + database: createDatabase(), }); const app = express().use(router); - await request(app).post('/v1/jobs').send({ - templateName: '', - values: {}, - }); + await request(app) + .post('/v1/jobs') + .send({ + templateName: '', + values: { + storePath: 'https://github.com/backstage/good', + }, + }); - expect(mockPrepare).toBeCalledWith(expect.anything(), { + expect(mockPrepare).toBeCalledWith({ logger: expect.anything(), - workingDirectory: '/path', + workspacePath: expect.stringContaining('path'), + url: expect.anything(), }); }); @@ -127,16 +153,23 @@ describe('createRouter - working directory', () => { config: new ConfigReader({}), dockerClient: new Docker(), entityClient: mockedEntityClient, + database: createDatabase(), }); const app = express().use(router); - await request(app).post('/v1/jobs').send({ - templateName: '', - values: {}, - }); + await request(app) + .post('/v1/jobs') + .send({ + templateName: '', + values: { + storePath: 'https://github.com/backstage/goodrepo', + }, + }); - expect(mockPrepare).toBeCalledWith(expect.anything(), { + expect(mockPrepare).toBeCalledWith({ logger: expect.anything(), + workspacePath: expect.anything(), + url: expect.anything(), }); }); }); @@ -190,6 +223,7 @@ describe('createRouter', () => { config: new ConfigReader({}), dockerClient: new Docker(), entityClient: generateEntityClient(template), + database: createDatabase(), }); app = express().use(router); }); @@ -200,10 +234,14 @@ describe('createRouter', () => { describe('POST /v1/jobs', () => { it('rejects template values which do not match the template schema definition', async () => { - const response = await request(app).post('/v1/jobs').send({ - templateName: '', - values: {}, - }); + const response = await request(app) + .post('/v1/jobs') + .send({ + templateName: '', + values: { + storePath: 'https://github.com/backstage/backstage', + }, + }); expect(response.status).toEqual(400); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index ffd4d6487c..5821edfe8c 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -14,22 +14,37 @@ * limitations under the License. */ -import { Config, JsonValue } from '@backstage/config'; -import fs from 'fs-extra'; +import { Config } from '@backstage/config'; import Docker from 'dockerode'; import express from 'express'; +import { resolve as resolvePath } from 'path'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { JobProcessor, PreparerBuilder, - RequiredTemplateValues, - StageContext, TemplaterBuilder, + TemplaterValues, PublisherBuilder, + parseLocationAnnotation, + joinGitUrlPath, + FilePreparer, } from '../scaffolder'; import { CatalogEntityClient } from '../lib/catalog'; import { validate, ValidatorResult } from 'jsonschema'; +import parseGitUrl from 'git-url-parse'; +import { + DatabaseTaskStore, + StorageTaskBroker, + TaskWorker, +} from '../scaffolder/tasks'; +import { + TemplateActionRegistry, + templateEntityToSpec, +} from '../scaffolder/tasks/TemplateConverter'; +import { registerLegacyActions } from '../scaffolder/stages/legacy'; +import { getWorkingDirectory } from './helpers'; +import { PluginDatabaseManager } from '@backstage/backend-common'; export interface RouterOptions { preparers: PreparerBuilder; @@ -40,6 +55,7 @@ export interface RouterOptions { config: Config; dockerClient: Docker; entityClient: CatalogEntityClient; + database: PluginDatabaseManager; } export async function createRouter( @@ -56,30 +72,33 @@ export async function createRouter( config, dockerClient, entityClient, + database, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); - const jobProcessor = new JobProcessor(); + const workingDirectory = await getWorkingDirectory(config, logger); + const jobProcessor = await JobProcessor.fromConfig({ config, logger }); - let workingDirectory: string; - if (config.has('backend.workingDirectory')) { - workingDirectory = config.getString('backend.workingDirectory'); - try { - // Check if working directory exists and is writable - await fs.promises.access( - workingDirectory, - fs.constants.F_OK | fs.constants.W_OK, - ); - logger.info(`using working directory: ${workingDirectory}`); - } catch (err) { - logger.error( - `working directory ${workingDirectory} ${ - err.code === 'ENOENT' ? 'does not exist' : 'is not writable' - }`, - ); - throw err; - } - } + const databaseTaskStore = await DatabaseTaskStore.create( + await database.getClient(), + ); + const taskBroker = new StorageTaskBroker(databaseTaskStore, logger); + const actionRegistry = new TemplateActionRegistry(); + const worker = new TaskWorker({ + logger, + taskBroker, + actionRegistry, + workingDirectory, + }); + + registerLegacyActions(actionRegistry, { + dockerClient, + preparers, + publishers, + templaters, + }); + + worker.start(); router .get('/v1/job/:jobId', ({ params }, res) => { @@ -107,8 +126,12 @@ export async function createRouter( }) .post('/v1/jobs', async (req, res) => { const templateName: string = req.body.templateName; - const values: RequiredTemplateValues & Record = - req.body.values; + const values: TemplaterValues = { + ...req.body.values, + destination: { + git: parseGitUrl(req.body.values.storePath), + }, + }; const template = await entityClient.findTemplate(templateName); @@ -128,37 +151,62 @@ export async function createRouter( stages: [ { name: 'Prepare the skeleton', - handler: async ctx => { - const preparer = preparers.get(ctx.entity); - const skeletonDir = await preparer.prepare(ctx.entity, { + async handler(ctx) { + const { + protocol, + location: templateEntityLocation, + } = parseLocationAnnotation(ctx.entity); + + if (protocol === 'file') { + const preparer = new FilePreparer(); + + const path = resolvePath( + templateEntityLocation, + template.spec.path || '.', + ); + + await preparer.prepare({ + url: `file://${path}`, + logger: ctx.logger, + workspacePath: ctx.workspacePath, + }); + return; + } + + const preparer = preparers.get(templateEntityLocation); + + const url = joinGitUrlPath( + templateEntityLocation, + template.spec.path, + ); + + await preparer.prepare({ + url, logger: ctx.logger, - workingDirectory, + workspacePath: ctx.workspacePath, }); - return { skeletonDir }; }, }, { name: 'Run the templater', - handler: async (ctx: StageContext<{ skeletonDir: string }>) => { - const templater = templaters.get(ctx.entity); - const { resultDir } = await templater.run({ - directory: ctx.skeletonDir, + async handler(ctx) { + const templater = templaters.get(ctx.entity.spec.templater); + await templater.run({ + workspacePath: ctx.workspacePath, dockerClient, logStream: ctx.logStream, values: ctx.values, }); - - return { resultDir }; }, }, { name: 'Publish template', - handler: async (ctx: StageContext<{ resultDir: string }>) => { - const publisher = publishers.get(ctx.entity); + handler: async ctx => { + const publisher = publishers.get(ctx.values.storePath); ctx.logger.info('Will now store the template'); const result = await publisher.publish({ values: ctx.values, - directory: ctx.resultDir, + workspacePath: ctx.workspacePath, logger: ctx.logger, }); return result; @@ -167,9 +215,78 @@ export async function createRouter( ], }); - res.status(201).json({ id: job.id }); - jobProcessor.run(job); + + res.status(201).json({ id: job.id }); + }); + + // NOTE: The v2 API is unstable + router + .post('/v2/tasks', async (req, res) => { + const templateName: string = req.body.templateName; + const values: TemplaterValues = { + ...req.body.values, + destination: { + git: parseGitUrl(req.body.values.storePath), + }, + }; + const template = await entityClient.findTemplate(templateName); + + const validationResult: ValidatorResult = validate( + values, + template.spec.schema, + ); + + if (!validationResult.valid) { + res.status(400).json({ errors: validationResult.errors }); + return; + } + const taskSpec = templateEntityToSpec(template, values); + const result = await taskBroker.dispatch(taskSpec); + + res.status(201).json({ id: result.taskId }); + }) + .get('/v2/tasks/:taskId/eventstream', async (req, res) => { + const { taskId } = req.params; + const after = Number(req.query.after) || undefined; + logger.debug(`Event stream observing taskId '${taskId}' opened`); + + // Mandatory headers and http status to keep connection open + res.writeHead(200, { + Connection: 'keep-alive', + 'Cache-Control': 'no-cache', + 'Content-Type': 'text/event-stream', + }); + + // After client opens connection send all events as string + const unsubscribe = taskBroker.observe( + { taskId, after }, + (error, { events }) => { + if (error) { + logger.error( + `Received error from event stream when observing taskId '${taskId}', ${error}`, + ); + } + + for (const event of events) { + res.write( + `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`, + ); + if (event.type === 'completion') { + unsubscribe(); + // Closing the event stream here would cause the frontend + // to automatically reconnect because it lost connection. + } + } + res.flush(); + }, + ); + // When client closes connection we update the clients list + // avoiding the disconnected one + req.on('close', () => { + unsubscribe(); + logger.debug(`Event stream observing taskId '${taskId}' closed`); + }); }); const app = express(); diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 734a7157d1..cd7c8fe685 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,10 +1,115 @@ # @backstage/plugin-scaffolder +## 0.4.2 + +### Patch Changes + +- 720149854: Migrated to new composability API, exporting the plugin as `scaffolderPlugin`. The template list page (`/create`) is exported as the `TemplateIndexPage` extension, and the templating page itself is exported as `TemplatePage`. +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.4.1 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/plugin-catalog@0.2.14 + +## 0.4.0 + +### Minor Changes + +- ed6baab66: - Deprecating the `scaffolder.${provider}.token` auth duplication and favoring `integrations.${provider}` instead. If you receive deprecation warnings your config should change like the following: + + ```yaml + scaffolder: + github: + token: + $env: GITHUB_TOKEN + visibility: public + ``` + + To something that looks like this: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + scaffolder: + github: + visibility: public + ``` + + You can also configure multiple different hosts under the `integration` config like the following: + + ```yaml + integration: + github: + - host: github.com + token: + $env: GITHUB_TOKEN + - host: ghe.mycompany.com + token: + $env: GITHUB_ENTERPRISE_TOKEN + ``` + + This of course is the case for all the providers respectively. + + - Adding support for cross provider scaffolding, you can now create repositories in for example Bitbucket using a template residing in GitHub. + + - Fix GitLab scaffolding so that it returns a `catalogInfoUrl` which automatically imports the project into the catalog. + + - The `Store Path` field on the `scaffolder` frontend has now changed so that you require the full URL to the desired destination repository. + + `backstage/new-repository` would become `https://github.com/backstage/new-repository` if provider was GitHub for example. + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + +## 0.3.6 + +### Patch Changes + +- 8e083f41f: Bug fix: User can retry creating a new component if an error occurs, without having to reload the page. +- 947d3c269: You can now maximize the logs into full-screen by clicking the button under each step of the job +- Updated dependencies [9c09a364f] + - @backstage/plugin-catalog@0.2.10 + ## 0.3.5 ### Patch Changes -- 19554f6d6: Added Github Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` +- 19554f6d6: Added GitHub Actions for Create React App, and allow better imports of files inside a module when they're exposed using `files` in `package.json` - Updated dependencies [1dc445e89] - Updated dependencies [342270e4d] - @backstage/core@0.4.2 diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 812a5585d4..eff16ab0f5 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -14,7 +14,32 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { discoveryApiRef } from '@backstage/core'; +import { CatalogClient } from '@backstage/catalog-client'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { TemplateIndexPage, TemplatePage } from '../src/plugin'; +import { ScaffolderApi, scaffolderApiRef } from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerApi({ + api: catalogApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }), + }) + .registerApi({ + api: scaffolderApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }), + }) + .addPage({ + path: '/create', + title: 'Create', + element: , + }) + .addPage({ + path: '/create/:templateName', + element: , + }) + .render(); diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 8cd45779e1..56beb7b10b 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.3.5", + "version": "0.4.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,16 +30,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.8", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@rjsf/core": "^2.4.0", "@rjsf/material-ui": "^2.4.0", "classnames": "^2.2.6", + "git-url-parse": "^11.4.4", "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -50,9 +51,10 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", + "@backstage/catalog-client": "^0.3.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/scaffolder/src/components/JobStage/JobStage.tsx b/plugins/scaffolder/src/components/JobStage/JobStage.tsx index 8b3fc04de0..f285cb98c0 100644 --- a/plugins/scaffolder/src/components/JobStage/JobStage.tsx +++ b/plugins/scaffolder/src/components/JobStage/JobStage.tsx @@ -18,10 +18,12 @@ import { Accordion, AccordionDetails, AccordionSummary, + AccordionActions, Box, CircularProgress, LinearProgress, Typography, + Button, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; @@ -29,6 +31,7 @@ import ExpandLessIcon from '@material-ui/icons/ExpandLess'; import cn from 'classnames'; import moment from 'moment'; import React, { Suspense, useEffect, useState } from 'react'; +import { LogModal } from './LogModal'; import { Job } from '../../types'; const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog')); @@ -111,6 +114,9 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { .humanize() : null; + const [logsFullScreen, setLogsFullScreen] = useState(false); + const toggleLogsFullScreen = () => setLogsFullScreen(!logsFullScreen); + return ( { ) : ( }> +
- +
)} + + +
); }; diff --git a/plugins/scaffolder/src/components/JobStage/LogModal.tsx b/plugins/scaffolder/src/components/JobStage/LogModal.tsx new file mode 100644 index 0000000000..73b6253510 --- /dev/null +++ b/plugins/scaffolder/src/components/JobStage/LogModal.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 { + Dialog, + DialogTitle, + DialogContent, + IconButton, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Close from '@material-ui/icons/Close'; +import LazyLog from 'react-lazylog/build/LazyLog'; + +type Props = { + log: string[]; + open?: boolean; + onClose(): void; +}; + +const useStyles = makeStyles(theme => ({ + header: { + width: '100%', + padding: theme.spacing(1, 4), + }, + closeIcon: { + float: 'right', + padding: theme.spacing(0.5, 0), + }, + logs: { + boxShadow: '-3px -1px 7px 0px rgba(50, 50, 50, 0.59)', + height: '100%', + width: '100%', + }, +})); + +export const LogModal = ({ log, open = false, onClose }: Props) => { + const classes = useStyles(); + + return ( + + + Logs + + + + + +
+ +
+
+
+ ); +}; diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 528d42b94f..27c5b4f18a 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -19,7 +19,7 @@ import { Box, Button, Paper, - Step, + Step as StepUI, StepContent, StepLabel, Stepper, @@ -67,7 +67,7 @@ export const MultistepJsonForm = ({ <> {steps.map(({ label, schema, ...formProps }) => ( - + {label}
-
+ ))}
{activeStep === steps.length && ( diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 39999f938f..75ee2d07df 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -27,7 +27,7 @@ import { useApi, WarningPanel, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Button, Grid, Link, Typography } from '@material-ui/core'; import React, { useEffect } from 'react'; import { Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 62963b58b8..f3f97be877 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index fd4e4c3ded..e1422d4ec1 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -27,18 +27,19 @@ import { catalogApiRef, entityRoute, entityRouteParams, -} from '@backstage/plugin-catalog'; +} from '@backstage/plugin-catalog-react'; import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; -import React, { useState, useCallback } from 'react'; +import parseGitUrl from 'git-url-parse'; +import React, { useCallback, useState } from 'react'; import { generatePath, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; import { rootRoute } from '../../routes'; +import { useJobPolling } from '../hooks/useJobPolling'; import { JobStatusModal } from '../JobStatusModal'; import { MultistepJsonForm } from '../MultistepJsonForm'; -import { useJobPolling } from '../hooks/useJobPolling'; const useTemplate = ( templateName: string, @@ -63,10 +64,10 @@ const OWNER_REPO_SCHEMA = { description: 'Who is going to own this component', }, storePath: { - format: 'GitHub user or org / Repo name', type: 'string' as const, title: 'Store path', - description: 'GitHub store path in org/repo format', + description: + 'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo', }, access: { type: 'string' as const, @@ -75,11 +76,6 @@ const OWNER_REPO_SCHEMA = { }, }, }; - -const REPO_FORMAT = { - 'GitHub user or org / Repo name': /[^\/]*\/[^\/]*/, -}; - export const TemplatePage = () => { const errorApi = useApi(errorApiRef); const catalogApi = useApi(catalogApiRef); @@ -96,8 +92,8 @@ export const TemplatePage = () => { ); const [jobId, setJobId] = useState(null); - const job = useJobPolling(jobId, async job => { - if (!job.metadata.catalogInfoUrl) { + const job = useJobPolling(jobId, async jobItem => { + if (!jobItem.metadata.catalogInfoUrl) { errorApi.post( new Error(`No catalogInfoUrl returned from the scaffolder`), ); @@ -107,7 +103,9 @@ export const TemplatePage = () => { try { const { entities: [createdEntity], - } = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl }); + } = await catalogApi.addLocation({ + target: jobItem.metadata.catalogInfoUrl, + }); const resolvedPath = generatePath( `/catalog/${entityRoute.path}`, @@ -126,8 +124,8 @@ export const TemplatePage = () => { const handleCreate = async () => { try { - const jobId = await scaffolderApi.scaffold(templateName, formState); - setJobId(jobId); + const id = await scaffolderApi.scaffold(templateName, formState); + setJobId(id); setModalOpen(true); } catch (e) { errorApi.post(e); @@ -182,7 +180,34 @@ export const TemplatePage = () => { { label: 'Choose owner and repo', schema: OWNER_REPO_SCHEMA, - customFormats: REPO_FORMAT, + validate: (formData, errors) => { + const { storePath } = formData; + try { + const parsedUrl = parseGitUrl(storePath); + + if ( + !parsedUrl.resource || + !parsedUrl.owner || + !parsedUrl.name + ) { + if (parsedUrl.resource === 'dev.azure.com') { + errors.storePath.addError( + "The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's", + ); + } else { + errors.storePath.addError( + 'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}', + ); + } + } + } catch (ex) { + errors.storePath.addError( + `Failed validation of the store pathn with message ${ex.message}`, + ); + } + + return errors; + }, }, ]} /> diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index f0fdf5b429..5f102e7853 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -14,6 +14,11 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + scaffolderPlugin, + scaffolderPlugin as plugin, + TemplateIndexPage, + TemplatePage, +} from './plugin'; export { ScaffolderApi, scaffolderApiRef } from './api'; export { rootRoute, templateRoute } from './routes'; diff --git a/plugins/scaffolder/src/plugin.test.ts b/plugins/scaffolder/src/plugin.test.ts index 3b4c92168d..03dd9fe465 100644 --- a/plugins/scaffolder/src/plugin.test.ts +++ b/plugins/scaffolder/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { scaffolderPlugin } from './plugin'; describe('scaffolder', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(scaffolderPlugin).toBeDefined(); }); }); diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 41f7a03249..ed20705185 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -18,13 +18,14 @@ import { createPlugin, createApiFactory, discoveryApiRef, + createRoutableExtension, } from '@backstage/core'; -import { ScaffolderPage } from './components/ScaffolderPage'; -import { TemplatePage } from './components/TemplatePage'; +import { ScaffolderPage as ScaffolderPageComponent } from './components/ScaffolderPage'; +import { TemplatePage as TemplatePageComponent } from './components/TemplatePage'; import { rootRoute, templateRoute } from './routes'; import { scaffolderApiRef, ScaffolderApi } from './api'; -export const plugin = createPlugin({ +export const scaffolderPlugin = createPlugin({ id: 'scaffolder', apis: [ createApiFactory({ @@ -34,7 +35,27 @@ export const plugin = createPlugin({ }), ], register({ router }) { - router.addRoute(rootRoute, ScaffolderPage); - router.addRoute(templateRoute, TemplatePage); + router.addRoute(rootRoute, ScaffolderPageComponent); + router.addRoute(templateRoute, TemplatePageComponent); + }, + routes: { + templateIndex: rootRoute, + template: templateRoute, }, }); + +export const TemplateIndexPage = scaffolderPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ScaffolderPage').then(m => m.ScaffolderPage), + mountPoint: rootRoute, + }), +); + +export const TemplatePage = scaffolderPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/TemplatePage').then(m => m.TemplatePage), + mountPoint: templateRoute, + }), +); diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 28c77f29a6..8efd1d3aaf 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -16,12 +16,11 @@ import { createRouteRef } from '@backstage/core'; export const rootRoute = createRouteRef({ - icon: () => null, path: '/create', title: 'Create new entity', }); + export const templateRoute = createRouteRef({ - icon: () => null, path: '/create/:templateName', title: 'Entity creation', }); diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index f85c13b47c..383cb3c484 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,51 @@ # @backstage/plugin-search +## 0.2.7 + +### Patch Changes + +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + +## 0.2.5 + +### Patch Changes + +- 01707438b: Fix Material-UI warning for search filtering +- Updated dependencies [9c09a364f] + - @backstage/plugin-catalog@0.2.10 + ## 0.2.4 ### Patch Changes diff --git a/plugins/search/dev/index.tsx b/plugins/search/dev/index.tsx index 264d6f801f..e6e97ead6d 100644 --- a/plugins/search/dev/index.tsx +++ b/plugins/search/dev/index.tsx @@ -14,6 +14,6 @@ * limitations under the License. */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { searchPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(searchPlugin).render(); diff --git a/plugins/search/package.json b/plugins/search/package.json index 1111e67abe..6142e65dd3 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "0.2.4", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.7", - "@backstage/catalog-model": "^0.6.0", - "@backstage/theme": "^0.2.2", + "@backstage/core": "^0.6.0", + "@backstage/catalog-model": "^0.7.1", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -43,8 +43,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index e74741e27b..9d88227a44 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { CatalogApi } from '@backstage/plugin-catalog'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { CatalogApi } from '@backstage/plugin-catalog-react'; export type Result = { name: string; diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index f81cd4f66d..c4d8809732 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -13,23 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useEffect } from 'react'; -import { useAsync } from 'react-use'; - -import { makeStyles, Typography, Grid, Divider } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; import { - Link, EmptyState, + Link, Progress, Table, TableColumn, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; - -import { FiltersButton, Filters, FiltersState } from '../Filters'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Divider, Grid, makeStyles, Typography } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import React, { useEffect, useState } from 'react'; +import { useAsync } from 'react-use'; import SearchApi, { Result, SearchResults } from '../../apis'; +import { Filters, FiltersButton, FiltersState } from '../Filters'; const useStyles = makeStyles(theme => ({ searchQuery: { @@ -59,11 +57,6 @@ type TableHeaderProps = { handleToggleFilters: () => void; }; -type Filters = { - selected: string; - checked: Array; -}; - // TODO: move out column to make the search result component more generic const columns: TableColumn[] = [ { diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index 77ad7f9266..572d75bea5 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -13,5 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin } from './plugin'; -export * from './components'; +export { searchPlugin, searchPlugin as plugin, SearchPage } from './plugin'; +export { + Filters, + FiltersButton, + SearchBar, + SearchPage as Router, + SearchResult, + SidebarSearch, +} from './components'; +export type { FiltersState } from './components'; diff --git a/plugins/search/src/plugin.test.ts b/plugins/search/src/plugin.test.ts index 92b8d5bcbf..902faeaf9e 100644 --- a/plugins/search/src/plugin.test.ts +++ b/plugins/search/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { plugin } from './plugin'; +import { searchPlugin } from './plugin'; describe('search', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(searchPlugin).toBeDefined(); }); }); diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts index 44c7bcb042..37503b7751 100644 --- a/plugins/search/src/plugin.ts +++ b/plugins/search/src/plugin.ts @@ -13,17 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import { SearchPage } from './components/SearchPage'; +import { + createPlugin, + createRouteRef, + createRoutableExtension, +} from '@backstage/core'; +import { SearchPage as SearchPageComponent } from './components/SearchPage'; export const rootRouteRef = createRouteRef({ path: '/search', title: 'search', }); -export const plugin = createPlugin({ +export const searchPlugin = createPlugin({ id: 'search', register({ router }) { - router.addRoute(rootRouteRef, SearchPage); + router.addRoute(rootRouteRef, SearchPageComponent); + }, + routes: { + root: rootRouteRef, }, }); + +export const SearchPage = searchPlugin.provide( + createRoutableExtension({ + component: () => import('./components/SearchPage').then(m => m.SearchPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index f5b5f5efd8..698f72020c 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,51 @@ # @backstage/plugin-sentry +## 0.3.4 + +### Patch Changes + +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + +## 0.3.2 + +### Patch Changes + +- 88da267cc: Port to new composability API by exporting new `EntitySentryContent` and `EntitySentryCard` component extensions. +- Updated dependencies [9c09a364f] + - @backstage/plugin-catalog@0.2.10 + ## 0.3.1 ### Patch Changes diff --git a/plugins/sentry/dev/index.tsx b/plugins/sentry/dev/index.tsx index c9313911b2..87e03605e4 100644 --- a/plugins/sentry/dev/index.tsx +++ b/plugins/sentry/dev/index.tsx @@ -15,17 +15,17 @@ */ import { Entity } from '@backstage/catalog-model'; -import { EntityProvider } from '@backstage/plugin-catalog'; import { Content, Header, Page } from '@backstage/core'; import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { Grid } from '@material-ui/core'; import React from 'react'; import { + EntitySentryCard, + EntitySentryContent, MockSentryApi, SentryApi, sentryApiRef, - EntitySentryCard, - EntitySentryContent, } from '../src'; import { SENTRY_PROJECT_SLUG_ANNOTATION } from '../src/components/useProjectSlug'; diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 5dcba49cf0..06b538289b 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.3.1", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.9", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -46,8 +46,8 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/sentry/src/extensions.tsx b/plugins/sentry/src/extensions.tsx index a66080f2b6..ea63cd472a 100644 --- a/plugins/sentry/src/extensions.tsx +++ b/plugins/sentry/src/extensions.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import React from 'react'; import { createComponentExtension, createRoutableExtension, } from '@backstage/core'; -import { useEntity } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; import { plugin, rootRouteRef } from './plugin'; export const EntitySentryContent = plugin.provide( diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 62f9d857a7..c422f9d72e 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,46 @@ # @backstage/plugin-sonarqube +## 0.1.10 + +### Patch Changes + +- 8dfdec613: Migrate to new composability API, exporting the plugin as `sonarQubePlugin` and card as `EntitySonarQubeCard`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.1.9 + +### Patch Changes + +- 49a67732f: Ask the SonarQube server for all support metrics prior to querying them for a project. + +## 0.1.8 + +### Patch Changes + +- 57f9d92cb: Add support for the security hotspots that are provided by SonarQube and SonarCloud. +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + ## 0.1.7 ### Patch Changes diff --git a/plugins/sonarqube/README.md b/plugins/sonarqube/README.md index 28bf196ea5..7afc9cc04a 100644 --- a/plugins/sonarqube/README.md +++ b/plugins/sonarqube/README.md @@ -42,6 +42,8 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( 4. Add the proxy config: + Provide a method for your Backstage backend to get to your SonarQube API end point. + **SonarCloud** ```yaml diff --git a/plugins/sonarqube/dev/index.tsx b/plugins/sonarqube/dev/index.tsx index 830d8aa575..2dd9236547 100644 --- a/plugins/sonarqube/dev/index.tsx +++ b/plugins/sonarqube/dev/index.tsx @@ -15,21 +15,61 @@ */ import { Entity } from '@backstage/catalog-model'; -import { - Content, - createPlugin, - createRouteRef, - Header, - Page, -} from '@backstage/core'; -import { createDevApp } from '@backstage/dev-utils'; +import { Content, Header, Page } from '@backstage/core'; +import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; import { Grid } from '@material-ui/core'; import React from 'react'; -import { SonarQubeCard } from '../src'; +import { EntitySonarQubeCard, sonarQubePlugin } from '../src'; import { FindingSummary, SonarQubeApi, sonarQubeApiRef } from '../src/api'; import { SONARQUBE_PROJECT_KEY_ANNOTATION } from '../src/components/useProjectKey'; +const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [SONARQUBE_PROJECT_KEY_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + createDevApp() + .registerPlugin(sonarQubePlugin) + .addPage({ + title: 'Cards', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }) .registerApi({ api: sonarQubeApiRef, deps: {}, @@ -59,6 +99,8 @@ createDevApp() projectUrl: `/#${componentKey}`, getIssuesUrl: i => `/#${componentKey}/issues/${i}`, getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + getSecurityHotspotsUrl: () => + `#${componentKey}/security_hotspots`, } as FindingSummary; case 'failed': @@ -70,6 +112,7 @@ createDevApp() reliability_rating: '2.0', vulnerabilities: '18', security_rating: '3.0', + security_review_rating: '3.0', code_smells: '22', sqale_rating: '5.0', coverage: '15.7', @@ -78,6 +121,8 @@ createDevApp() projectUrl: `/#${componentKey}`, getIssuesUrl: i => `/#${componentKey}/issues/${i}`, getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + getSecurityHotspotsUrl: () => + `#${componentKey}/security_hotspots`, } as FindingSummary; case 'passed': @@ -89,6 +134,8 @@ createDevApp() reliability_rating: '1.0', vulnerabilities: '0', security_rating: '1.0', + security_hotspots_reviewed: '100.0', + security_review_rating: '1.0', code_smells: '0', sqale_rating: '1.0', coverage: '100.0', @@ -97,6 +144,8 @@ createDevApp() projectUrl: `/#${componentKey}`, getIssuesUrl: i => `/#${componentKey}/issues/${i}`, getComponentMeasuresUrl: i => `/#${componentKey}/measures/${i}`, + getSecurityHotspotsUrl: () => + `#${componentKey}/security_hotspots`, } as FindingSummary; default: @@ -105,58 +154,4 @@ createDevApp() }, } as SonarQubeApi), }) - .registerPlugin( - createPlugin({ - id: 'defectdojo-demo', - register({ router }) { - const entity = (name?: string) => - ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - annotations: { - [SONARQUBE_PROJECT_KEY_ANNOTATION]: name, - }, - name: name, - }, - } as Entity); - - const ExamplePage = () => ( - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - ); - - router.addRoute( - createRouteRef({ path: '/', title: 'SonarQube' }), - ExamplePage, - ); - }, - }), - ) .render(); diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 05cc32d681..a31ee320f2 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.7", + "version": "0.1.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/plugin-catalog-react": "^0.0.2", + "@backstage/core": "^0.6.0", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -46,8 +47,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/sonarqube/src/api/SonarQubeApi.ts b/plugins/sonarqube/src/api/SonarQubeApi.ts index 5734f9a3ca..0715d1fd02 100644 --- a/plugins/sonarqube/src/api/SonarQubeApi.ts +++ b/plugins/sonarqube/src/api/SonarQubeApi.ts @@ -30,6 +30,7 @@ export interface FindingSummary { projectUrl: string; getIssuesUrl: SonarUrlProcessorFunc; getComponentMeasuresUrl: SonarUrlProcessorFunc; + getSecurityHotspotsUrl: () => string; } export const sonarQubeApiRef = createApiRef({ diff --git a/plugins/sonarqube/src/api/SonarQubeClient.test.ts b/plugins/sonarqube/src/api/SonarQubeClient.test.ts index a3ae24de52..e5cbc2ccdf 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.test.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.test.ts @@ -29,7 +29,31 @@ describe('SonarQubeClient', () => { const mockBaseUrl = 'http://backstage:9191/api/proxy'; const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); - const setupHandlers = () => { + const setupHandlers = ( + metricKeys = [ + 'alert_status', + 'bugs', + 'reliability_rating', + 'vulnerabilities', + 'security_rating', + 'security_hotspots_reviewed', + 'security_review_rating', + 'code_smells', + 'sqale_rating', + 'coverage', + 'duplicated_lines_density', + ], + ) => { + server.use( + rest.get(`${mockBaseUrl}/sonarqube/metrics/search`, (_, res, ctx) => { + return res( + ctx.json({ + metrics: metricKeys.map(k => ({ key: k })), + }), + ); + }), + ); + server.use( rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe('component=our-service'); @@ -46,8 +70,9 @@ describe('SonarQubeClient', () => { server.use( rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - 'projectKeys=our-service&metricKeys=alert_status%2Cbugs%2Creliability_rating%2Cvulnerabilities%2Csecurity_rating%2Ccode_smells%2Csqale_rating%2Ccoverage%2Cduplicated_lines_density', + `projectKeys=our-service&metricKeys=${metricKeys.join('%2C')}`, ); + return res( ctx.json({ measures: [ @@ -81,6 +106,16 @@ describe('SonarQubeClient', () => { value: '1.0', component: 'our-service', }, + { + metric: 'security_hotspots_reviewed', + value: '100', + component: 'our-service', + }, + { + metric: 'security_review_rating', + value: '1.0', + component: 'our-service', + }, { metric: 'code_smells', value: '100', @@ -101,7 +136,7 @@ describe('SonarQubeClient', () => { value: '1.0', component: 'our-service', }, - ], + ].filter(m => metricKeys.includes(m.metric)), } as MeasuresWrapper), ); }), @@ -123,6 +158,8 @@ describe('SonarQubeClient', () => { reliability_rating: '3.0', vulnerabilities: '4', security_rating: '1.0', + security_hotspots_reviewed: '100', + security_review_rating: '1.0', code_smells: '100', sqale_rating: '2.0', coverage: '55.5', @@ -158,6 +195,8 @@ describe('SonarQubeClient', () => { reliability_rating: '3.0', vulnerabilities: '4', security_rating: '1.0', + security_hotspots_reviewed: '100', + security_review_rating: '1.0', code_smells: '100', sqale_rating: '2.0', coverage: '55.5', @@ -173,4 +212,32 @@ describe('SonarQubeClient', () => { 'http://a.instance.local/component_measures?id=our-service&metric=coverage&resolved=false&view=list', ); }); + + it('should only request selected metrics', async () => { + setupHandlers(['alert_status', 'bugs']); + + const client = new SonarQubeClient({ + discoveryApi, + baseUrl: 'http://a.instance.local', + }); + + const summary = await client.getFindingSummary('our-service'); + + expect(summary).toEqual( + expect.objectContaining({ + lastAnalysis: '2020-01-01T00:00:00Z', + metrics: { + alert_status: 'OK', + bugs: '2', + }, + projectUrl: 'http://a.instance.local/dashboard?id=our-service', + }) as FindingSummary, + ); + expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual( + 'http://a.instance.local/project/issues?id=our-service&types=CODE_SMELL&resolved=false', + ); + expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual( + 'http://a.instance.local/component_measures?id=our-service&metric=coverage&resolved=false&view=list', + ); + }); }); diff --git a/plugins/sonarqube/src/api/SonarQubeClient.ts b/plugins/sonarqube/src/api/SonarQubeClient.ts index 893f32b0a6..35035b33c5 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.ts @@ -43,6 +43,13 @@ export class SonarQubeClient implements SonarQubeApi { return undefined; } + private async getSupportedMetrics(): Promise { + const result = await this.callApi<{ metrics: Array<{ key: string }> }>( + 'metrics/search', + ); + return result?.metrics?.map(m => m.key) ?? []; + } + async getFindingSummary( componentKey?: string, ): Promise { @@ -63,16 +70,24 @@ export class SonarQubeClient implements SonarQubeApi { reliability_rating: undefined, vulnerabilities: undefined, security_rating: undefined, + security_hotspots_reviewed: undefined, + security_review_rating: undefined, code_smells: undefined, sqale_rating: undefined, coverage: undefined, duplicated_lines_density: undefined, }; + // select the metrics that are supported by the SonarQube instance + const supportedMetrics = await this.getSupportedMetrics(); + const metricKeys = Object.keys(metrics).filter(m => + supportedMetrics.includes(m), + ); + const measures = await this.callApi( - `measures/search?projectKeys=${componentKey}&metricKeys=${Object.keys( - metrics, - ).join(',')}`, + `measures/search?projectKeys=${componentKey}&metricKeys=${metricKeys.join( + ',', + )}`, ); if (!measures) { return undefined; @@ -92,10 +107,12 @@ export class SonarQubeClient implements SonarQubeApi { `${ this.baseUrl }project/issues?id=${componentKey}&types=${identifier.toUpperCase()}&resolved=false`, - getComponentMeasuresUrl: (identifier: string) => + getComponentMeasuresUrl: identifier => `${ this.baseUrl }component_measures?id=${componentKey}&metric=${identifier.toLowerCase()}&resolved=false&view=list`, + getSecurityHotspotsUrl: () => + `${this.baseUrl}project/security_hotspots?id=${componentKey}`, }; } } diff --git a/plugins/sonarqube/src/api/types.ts b/plugins/sonarqube/src/api/types.ts index fc99b2d87d..1e0593126f 100644 --- a/plugins/sonarqube/src/api/types.ts +++ b/plugins/sonarqube/src/api/types.ts @@ -42,6 +42,10 @@ export type MetricKey = | 'code_smells' | 'sqale_rating' + // security hotspots + | 'security_hotspots_reviewed' + | 'security_review_rating' + // coverage | 'coverage' diff --git a/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx index 6a94f7e8f4..37e978bbe9 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx @@ -22,6 +22,7 @@ const useStyles = makeStyles(theme => { return { root: { margin: theme.spacing(1, 0), + minWidth: '140px', }, upper: { display: 'flex', diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx index a68a022717..5577048fa9 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -22,10 +22,12 @@ import { Progress, useApi, } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Chip, Grid } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import BugReport from '@material-ui/icons/BugReport'; import LockOpen from '@material-ui/icons/LockOpen'; +import Security from '@material-ui/icons/Security'; import SentimentVeryDissatisfied from '@material-ui/icons/SentimentVeryDissatisfied'; import React, { useMemo } from 'react'; import { useAsync } from 'react-use'; @@ -68,10 +70,10 @@ const useStyles = makeStyles(theme => ({ }, })); -interface DuplicationRating { +type DuplicationRating = { greaterThan: number; rating: '1.0' | '2.0' | '3.0' | '4.0' | '5.0'; -} +}; const defaultDuplicationRatings: DuplicationRating[] = [ { greaterThan: 0, rating: '1.0' }, @@ -82,14 +84,14 @@ const defaultDuplicationRatings: DuplicationRating[] = [ ]; export const SonarQubeCard = ({ - entity, variant = 'gridItem', duplicationRatings = defaultDuplicationRatings, }: { - entity: Entity; + entity?: Entity; variant?: string; duplicationRatings?: DuplicationRating[]; }) => { + const { entity } = useEntity(); const sonarQubeApi = useApi(sonarQubeApiRef); const projectTitle = useProjectKey(entity); @@ -205,6 +207,25 @@ export const SonarQubeCard = ({ leftSlot={} rightSlot={} /> + {value.metrics.security_review_rating && ( + } + title="Hotspots Reviewed" + link={value.getSecurityHotspotsUrl()} + leftSlot={ + + } + rightSlot={ + + } + /> + )}
{ it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(sonarQubePlugin).toBeDefined(); }); }); diff --git a/plugins/sonarqube/src/plugin.ts b/plugins/sonarqube/src/plugin.ts index f8b8cafc5c..3f58b24abf 100644 --- a/plugins/sonarqube/src/plugin.ts +++ b/plugins/sonarqube/src/plugin.ts @@ -17,12 +17,13 @@ import { configApiRef, createApiFactory, + createComponentExtension, createPlugin, discoveryApiRef, } from '@backstage/core'; import { sonarQubeApiRef, SonarQubeClient } from './api'; -export const plugin = createPlugin({ +export const sonarQubePlugin = createPlugin({ id: 'sonarqube', apis: [ createApiFactory({ @@ -36,3 +37,12 @@ export const plugin = createPlugin({ }), ], }); + +export const EntitySonarQubeCard = sonarQubePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/SonarQubeCard').then(m => m.SonarQubeCard), + }, + }), +); diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 246ea9c8f7..49a5f8bfcd 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,37 @@ # @backstage/plugin-tech-radar +## 0.3.4 + +### Patch Changes + +- 90c8f20b9: Fix mapping RadarEntry and Entry for moved and url attributes + Fix clicking of links in the radar legend +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [54c7d02f7] + - @backstage/core@0.6.0 + - @backstage/theme@0.2.3 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] + - @backstage/core@0.5.0 + +## 0.3.2 + +### Patch Changes + +- ab0892358: Remove test dependencies from production package list +- bc909178d: Updated example data in `README`. + ## 0.3.1 ### Patch Changes diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index ea30f15a67..a5c9f7bb53 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.3.1", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/test-utils": "^0.1.5", - "@backstage/theme": "^0.2.2", + "@backstage/core": "^0.6.0", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -44,8 +43,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 583a7c91e0..759fec62c3 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -59,7 +59,7 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { ring: loaderResponse!.rings.find( r => r.id === entry.timeline[0].ringId, )!, - history: entry.timeline.map(e => { + timeline: entry.timeline.map(e => { return { date: e.date, ring: loaderResponse!.rings.find(a => a.id === e.ringId)!, @@ -67,6 +67,8 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { moved: e.moved, }; }), + moved: entry.timeline[0].moved, + url: entry.url, }; }); }; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index e319846d78..e10993b010 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -71,12 +71,12 @@ const useStyles = makeStyles(theme => ({ 'font-feature-settings': 'pnum', }, entry: { - pointerEvents: 'none', + pointerEvents: 'visiblePainted', userSelect: 'none', fontSize: '11px', }, entryLink: { - pointerEvents: 'none', + pointerEvents: 'visiblePainted', }, })); diff --git a/plugins/tech-radar/src/sampleData.ts b/plugins/tech-radar/src/sampleData.ts index 914bc30931..613e7b3cdc 100644 --- a/plugins/tech-radar/src/sampleData.ts +++ b/plugins/tech-radar/src/sampleData.ts @@ -53,7 +53,7 @@ entries.push({ entries.push({ timeline: [ { - moved: 0, + moved: -1, ringId: 'use', date: new Date('2020-08-06'), description: @@ -69,7 +69,7 @@ entries.push({ entries.push({ timeline: [ { - moved: 0, + moved: 1, ringId: 'use', date: new Date('2020-08-06'), }, @@ -88,7 +88,7 @@ entries.push({ date: new Date('2020-08-06'), }, ], - url: '#', + url: 'https://reactjs.org/', key: 'react', id: 'react', title: 'React', diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index a16449d9e3..28ba4e032c 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,80 @@ # @backstage/plugin-techdocs-backend +## 0.5.5 + +### Patch Changes + +- c777df180: 1. Added option to use Azure Blob Storage as a choice to store the static generated files for TechDocs. +- e44925723: `techdocs.requestUrl` and `techdocs.storageUrl` are now optional configs and the discovery API will be used to get the URL where techdocs plugin is hosted. +- Updated dependencies [c777df180] +- Updated dependencies [2430ee7c2] +- Updated dependencies [6e612ce25] +- Updated dependencies [e44925723] +- Updated dependencies [025e122c3] +- Updated dependencies [7881f2117] +- Updated dependencies [f0320190d] +- Updated dependencies [11cb5ef94] + - @backstage/techdocs-common@0.3.7 + - @backstage/backend-common@0.5.2 + - @backstage/catalog-model@0.7.1 + +## 0.5.4 + +### Patch Changes + +- a5e27d5c1: Create type for TechDocsMetadata (#3716) + + This change introduces a new type (TechDocsMetadata) in packages/techdocs-common. This type is then introduced in the endpoint response in techdocs-backend and in the api interface in techdocs (frontend). + +- Updated dependencies [def2307f3] +- Updated dependencies [0b135e7e0] +- Updated dependencies [294a70cab] +- Updated dependencies [0ea032763] +- Updated dependencies [5345a1f98] +- Updated dependencies [53c9c51f2] +- Updated dependencies [a5e27d5c1] +- Updated dependencies [09a370426] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/backend-common@0.5.0 + - @backstage/techdocs-common@0.3.5 + +## 0.5.3 + +### Patch Changes + +- 68ad5af51: Improve techdocs-common Generator API for it to be used by techdocs-cli. TechDocs generator.run function now takes + an input AND an output directory. Most probably you use techdocs-common via plugin-techdocs-backend, and so there + is no breaking change for you. + But if you use techdocs-common separately, you need to create an output directory and pass into the generator. +- cb7af51e7: If using Url Reader, cache downloaded source files for 30 minutes. +- Updated dependencies [68ad5af51] +- Updated dependencies [f3b064e1c] +- Updated dependencies [371f67ecd] +- Updated dependencies [f1e74777a] +- Updated dependencies [dbe4450c3] +- Updated dependencies [5826d0973] +- Updated dependencies [b3b9445df] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/techdocs-common@0.3.3 + - @backstage/catalog-model@0.6.1 + - @backstage/backend-common@0.4.3 + +## 0.5.2 + +### Patch Changes + +- 7ec525481: 1. Added option to use AWS S3 as a choice to store the static generated files for TechDocs. +- Updated dependencies [7ec525481] +- Updated dependencies [5ecd50f8a] +- Updated dependencies [f8ba88ded] +- Updated dependencies [00042e73c] +- Updated dependencies [0829ff126] +- Updated dependencies [036a84373] + - @backstage/techdocs-common@0.3.2 + - @backstage/backend-common@0.4.2 + ## 0.5.1 ### Patch Changes diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index b51d86138a..7ec1162d03 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -14,17 +14,12 @@ * limitations under the License. */ /** - * techdocs schema below is an abstract of what's used within techdocs-backend and for its visibility - * to view the complete techdoc schema please refer: plugins/techdocs/config.d.ts + * TechDocs schema below is an abstract of what's used within techdocs-backend and for its visibility + * to view the complete TechDocs schema please refer: plugins/techdocs/config.d.ts * */ export interface Config { /** Configuration options for the techdocs-backend plugin */ techdocs: { - /** - * attr: 'storageUrl' - accepts a string value - * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs - */ - storageUrl: string; /** * documentation building process depends on the builder attr * attr: 'builder' - accepts a string value @@ -45,5 +40,11 @@ export interface Config { */ type: 'local' | 'googleGcs' | 'awsS3'; }; + /** + * attr: 'storageUrl' - accepts a string value + * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs + * @deprecated + */ + storageUrl?: string; }; } diff --git a/plugins/techdocs-backend/examples/documented-component/documented-component.yaml b/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml similarity index 61% rename from plugins/techdocs-backend/examples/documented-component/documented-component.yaml rename to plugins/techdocs-backend/examples/documented-component/catalog-info.yaml index 800116344f..d609866f9b 100644 --- a/plugins/techdocs-backend/examples/documented-component/documented-component.yaml +++ b/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml @@ -4,7 +4,7 @@ metadata: name: documented-component description: A Service with TechDocs documentation annotations: - backstage.io/techdocs-ref: 'github:https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/examples/documented-component' + backstage.io/techdocs-ref: 'url:https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend/examples/documented-component' spec: type: service lifecycle: experimental diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index ed19e4e23a..36e5bd3bcd 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.5.1", + "version": "0.5.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,21 +30,22 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.4.1", - "@backstage/catalog-model": "^0.6.0", + "@backstage/backend-common": "^0.5.2", + "@backstage/catalog-model": "^0.7.1", "@backstage/config": "^0.1.2", - "@backstage/techdocs-common": "^0.3.1", + "@backstage/techdocs-common": "^0.3.7", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^3.0.3", + "fs-extra": "^9.0.1", "knex": "^0.21.6", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.4", + "@backstage/cli": "^0.6.0", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts index 863f587a96..33d3120c47 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/builder.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -13,6 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; import Docker from 'dockerode'; import { Logger } from 'winston'; import { Entity } from '@backstage/catalog-model'; @@ -78,8 +81,17 @@ export class DocsBuilder { const parsedLocationAnnotation = getLocationForEntity(this.entity); this.logger.info(`Running generator on entity ${getEntityId(this.entity)}`); - const { resultDir } = await this.generator.run({ - directory: preparedDir, + // Create a temporary directory to store the generated files in. + const tmpdirPath = os.tmpdir(); + // Fixes a problem with macOS returning a path that is a symlink + const tmpdirResolvedPath = fs.realpathSync(tmpdirPath); + const outputDir = await fs.mkdtemp( + path.join(tmpdirResolvedPath, 'techdocs-tmp-'), + ); + + await this.generator.run({ + inputDir: preparedDir, + outputDir, dockerClient: this.dockerClient, parsedLocationAnnotation, }); @@ -87,9 +99,11 @@ export class DocsBuilder { this.logger.info(`Running publisher on entity ${getEntityId(this.entity)}`); await this.publisher.publish({ entity: this.entity, - directory: resultDir, + directory: outputDir, }); + // TODO: Remove the generated directory once published. + if (!this.entity.metadata.uid) { throw new Error( 'Trying to build documentation for entity not in service catalog', @@ -130,6 +144,18 @@ export class DocsBuilder { } } + // Cache downloaded source files for 30 minutes. + // TODO: When urlReader/readTree supports some way to get latest commit timestamp, + // it should be used to invalidate cache. + if (type === 'url') { + const builtAt = buildMetadataStorage.getTimestamp(); + const now = Date.now(); + + if (builtAt > now - 1800000) { + return true; + } + } + this.logger.debug( `Docs for entity ${getEntityId(this.entity)} was outdated.`, ); diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index c2ecaab61b..fe7b52ab52 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -58,14 +58,22 @@ export async function createRouter({ const { '0': path } = req.params; const entityName = getEntityNameFromUrlPath(path); - publisher - .fetchTechDocsMetadata(entityName) - .then(techdocsMetadataJson => { - res.send(techdocsMetadataJson); - }) - .catch(reason => { - res.status(500).send(`Unable to get Metadata. Reason: ${reason}`); - }); + try { + const techdocsMetadata = await publisher.fetchTechDocsMetadata( + entityName, + ); + + res.send(techdocsMetadata); + } catch (err) { + logger.error( + `Unable to get metadata for ${entityName.namespace}/${entityName.name} with error ${err}`, + ); + res + .status(500) + .send( + `Unable to get metadata for $${entityName.namespace}/${entityName.name}, reason: ${err}`, + ); + } }); router.get('/metadata/entity/:namespace/:kind/:name', async (req, res) => { @@ -94,7 +102,9 @@ export async function createRouter({ router.get('/docs/:namespace/:kind/:name/*', async (req, res) => { const { kind, namespace, name } = req.params; - const storageUrl = config.getString('techdocs.storageUrl'); + const storageUrl = + config.getOptionalString('techdocs.storageUrl') ?? + `${await discovery.getBaseUrl('techdocs')}/static/docs`; const catalogUrl = await discovery.getBaseUrl('catalog'); const triple = [kind, namespace, name].map(encodeURIComponent).join('/'); @@ -140,6 +150,7 @@ export async function createRouter({ } break; case 'awsS3': + case 'azureBlobStorage': case 'googleGcs': // This block should be valid for all external storage implementations. So no need to duplicate in future, // add the publisher type in the list here. diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 721c10a1ec..0fd23a4f7d 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -17,6 +17,7 @@ import { createServiceBuilder, SingleHostDiscovery, + UrlReader, } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -49,10 +50,19 @@ export async function startStandaloneServer( }, }); const discovery = SingleHostDiscovery.fromConfig(config); + const mockUrlReader: jest.Mocked = { + read: jest.fn(), + readTree: jest.fn(), + search: jest.fn(), + }; logger.debug('Creating application...'); const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(config, logger); + const directoryPreparer = new DirectoryPreparer( + config, + logger, + mockUrlReader, + ); preparers.register('dir', directoryPreparer); const generators = new Generators(); diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 8bd79e4139..69fe41dd32 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,93 @@ # @backstage/plugin-techdocs +## 0.5.5 + +### Patch Changes + +- 5fa3bdb55: Add `href` in addition to `onClick` to `ItemCard`. Ensure that the height of a + `ItemCard` with and without tags is equal. +- e44925723: `techdocs.requestUrl` and `techdocs.storageUrl` are now optional configs and the discovery API will be used to get the URL where techdocs plugin is hosted. +- 019fe39a0: Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. +- Updated dependencies [c777df180] +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [7fc89bae2] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [6e612ce25] +- Updated dependencies [e44925723] +- Updated dependencies [025e122c3] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [7881f2117] +- Updated dependencies [f0320190d] +- Updated dependencies [54c7d02f7] +- Updated dependencies [11cb5ef94] + - @backstage/techdocs-common@0.3.7 + - @backstage/core@0.6.0 + - @backstage/plugin-catalog-react@0.0.2 + - @backstage/theme@0.2.3 + - @backstage/catalog-model@0.7.1 + +## 0.5.4 + +### Patch Changes + +- a5e27d5c1: Create type for TechDocsMetadata (#3716) + + This change introduces a new type (TechDocsMetadata) in packages/techdocs-common. This type is then introduced in the endpoint response in techdocs-backend and in the api interface in techdocs (frontend). + +- Updated dependencies [def2307f3] +- Updated dependencies [efd6ef753] +- Updated dependencies [593632f07] +- Updated dependencies [33846acfc] +- Updated dependencies [a187b8ad0] +- Updated dependencies [f04db53d7] +- Updated dependencies [53c9c51f2] +- Updated dependencies [a5e27d5c1] +- Updated dependencies [a93f42213] + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/plugin-catalog@0.2.12 + - @backstage/techdocs-common@0.3.5 + +## 0.5.3 + +### Patch Changes + +- dbe4450c3: Google Cloud authentication in TechDocs has been improved. + + 1. `techdocs.publisher.googleGcs.credentials` is now optional. If it is missing, `GOOGLE_APPLICATION_CREDENTIALS` + environment variable (and some other methods) will be used to authenticate. + Read more here https://cloud.google.com/docs/authentication/production + + 2. `techdocs.publisher.googleGcs.projectId` is no longer used. You can remove it from your `app-config.yaml`. + +- a6f9dca0d: Remove dependency on `@backstage/core-api`. No plugin should ever depend on that package; it's an internal concern whose important bits are re-exported by `@backstage/core` which is the public facing dependency to use. +- b3b9445df: AWS S3 authentication in TechDocs has been improved. + + 1. `techdocs.publisher.awsS3.bucketName` is now the only required config. `techdocs.publisher.awsS3.credentials` and `techdocs.publisher.awsS3.region` are optional. + + 2. If `techdocs.publisher.awsS3.credentials` and `techdocs.publisher.awsS3.region` are missing, the AWS environment variables `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_REGION` will be used. There are more better ways of setting up AWS authentication. Read the guide at https://backstage.io/docs/features/techdocs/using-cloud-storage + +- e5d12f705: Use `history.pushState` for hash link navigation. +- Updated dependencies [68ad5af51] +- Updated dependencies [f3b064e1c] +- Updated dependencies [371f67ecd] +- Updated dependencies [f1e74777a] +- Updated dependencies [dbe4450c3] +- Updated dependencies [c00488983] +- Updated dependencies [265a7ab30] +- Updated dependencies [5826d0973] +- Updated dependencies [b3b9445df] +- Updated dependencies [abbee6fff] +- Updated dependencies [147fadcb9] + - @backstage/techdocs-common@0.3.3 + - @backstage/catalog-model@0.6.1 + - @backstage/plugin-catalog@0.2.11 + - @backstage/core@0.4.4 + ## 0.5.2 ### Patch Changes diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index ad2daeb9a3..1104d75237 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -17,17 +17,6 @@ export interface Config { /** Configuration options for the techdocs plugin */ techdocs: { - /** - * attr: 'requestUrl' - accepts a string value - * e.g. requestUrl: http://localhost:7000/api/techdocs - * @visibility frontend - */ - requestUrl: string; - /** - * attr: 'storageUrl' - accepts a string value - * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs - */ - storageUrl: string; /** * documentation building process depends on the builder attr * attr: 'builder' - accepts a string value @@ -78,10 +67,13 @@ export interface Config { */ awsS3?: { /** - * Credentials used to access a storage bucket + * (Optional) Credentials used to access a storage bucket. + * If not set, environment variables or aws config file will be used to authenticate. + * https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html + * https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html * @visibility secret */ - credentials: { + credentials?: { /** * User access key id * attr: 'accessKeyId' - accepts a string value @@ -96,19 +88,62 @@ export interface Config { secretAccessKey: string; }; /** - * Cloud Storage Bucket Name + * (Required) Cloud Storage Bucket Name * attr: 'bucketName' - accepts a string value - * @visibility secret + * @visibility backend */ bucketName: string; /** - * AWS Region + * (Optional) AWS Region. + * If not set, AWS_REGION environment variable or aws config file will be used. + * https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html * attr: 'region' - accepts a string value * @visibility secret */ region?: string; }; } + | { + /** + * attr: 'type' - accepts a string value + * e.g. type: 'azureBlobStorage' + * alternatives: 'azureBlobStorage' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + type: 'azureBlobStorage'; + + /** + * azureBlobStorage required when 'type' is set to azureBlobStorage + */ + azureBlobStorage?: { + /** + * (Required) Credentials used to access a storage container. + * @visibility secret + */ + credentials: { + /** + * Account access name + * attr: 'account' - accepts a string value + * @visibility secret + */ + accountName: string; + /** + * (Optional) Account secret primary key + * If not set, environment variables will be used to authenticate. + * https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + * attr: 'accountKey' - accepts a string value + * @visibility secret + */ + accountKey?: string; + }; + /** + * (Required) Cloud Storage Container Name + * attr: 'containerName' - accepts a string value + * @visibility backend + */ + containerName: string; + }; + } | { /** * attr: 'type' - accepts a string value @@ -123,24 +158,33 @@ export interface Config { */ googleGcs?: { /** - * API key used to write to a storage bucket. + * (Required) Cloud Storage Bucket Name + * attr: 'bucketName' - accepts a string value + * @visibility backend + */ + bucketName: string; + /** + * (Optional) API key used to write to a storage bucket. + * If not set, environment variables will be used to authenticate. + * Read more: https://cloud.google.com/docs/authentication/production * attr: 'credentials' - accepts a string value * @visibility secret */ - credentials: string; - /** - * GCP Project ID where the Cloud Storage Bucket is hosted. - * attr: 'projectId' - accepts a string value - * @visibility secret - */ - projectId: string; - /** - * Cloud Storage Bucket Name - * attr: 'bucketName' - accepts a string value - * @visibility secret - */ - bucketName: string; + credentials?: string; }; }; + /** + * attr: 'requestUrl' - accepts a string value + * e.g. requestUrl: http://localhost:7000/api/techdocs + * @visibility frontend + * @deprecated + */ + requestUrl?: string; + /** + * attr: 'storageUrl' - accepts a string value + * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs + * @deprecated + */ + storageUrl?: string; }; } diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts index ae2b8c58ae..7d0679f6f6 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -13,20 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { DiscoveryApi } from '@backstage/core'; +import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import { TechDocsStorage } from '../src/api'; export class TechDocsDevStorageApi implements TechDocsStorage { - public apiOrigin: string; + public configApi: Config; + public discoveryApi: DiscoveryApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } async getEntityDocs(entityId: EntityName, path: string) { const { name } = entityId; - const url = `${this.apiOrigin}/${name}/${path}`; + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/${name}/${path}`; const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, @@ -39,8 +57,13 @@ export class TechDocsDevStorageApi implements TechDocsStorage { return request.text(); } - getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string { + async getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise { const { name } = entityId; - return new URL(oldBaseUrl, `${this.apiOrigin}/${name}/${path}`).toString(); + const apiOrigin = await this.getApiOrigin(); + return new URL(oldBaseUrl, `${apiOrigin}/${name}/${path}`).toString(); } } diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index 12974ef8b7..4cf74c9622 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -14,19 +14,21 @@ * limitations under the License. */ +import { configApiRef, discoveryApiRef } from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { techdocsPlugin } from '../src/plugin'; import { TechDocsDevStorageApi } from './api'; import { techdocsStorageApiRef } from '../src'; createDevApp() .registerApi({ api: techdocsStorageApiRef, - deps: {}, - factory: () => + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => new TechDocsDevStorageApi({ - apiOrigin: 'http://localhost:3000/api', + configApi, + discoveryApi, }), }) - .registerPlugin(plugin) + .registerPlugin(techdocsPlugin) .render(); diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index b87c158bc4..ad9e1f7bef 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.5.2", + "version": "0.5.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,13 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.6.0", - "@backstage/core": "^0.4.3", - "@backstage/core-api": "^0.2.8", - "@backstage/plugin-catalog": "^0.2.9", + "@backstage/config": "^0.1.2", + "@backstage/catalog-model": "^0.7.1", + "@backstage/core": "^0.6.0", + "@backstage/plugin-catalog-react": "^0.0.2", "@backstage/test-utils": "^0.1.6", - "@backstage/theme": "^0.2.2", - "@backstage/techdocs-common": "^0.3.1", + "@backstage/theme": "^0.2.3", + "@backstage/techdocs-common": "^0.3.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -50,8 +50,8 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 262f542ee6..b220912174 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -16,6 +16,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Route, Routes } from 'react-router-dom'; import { MissingAnnotationEmptyState } from '@backstage/core'; import { @@ -38,7 +39,14 @@ export const Router = () => { ); }; -export const EmbeddedDocsRouter = ({ entity }: { entity: Entity }) => { +type Props = { + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; +}; + +export const EmbeddedDocsRouter = (_props: Props) => { + const { entity } = useEntity(); + const projectId = entity.metadata.annotations?.[TECHDOCS_ANNOTATION]; if (!projectId) { diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts index 132976e280..734650d7c8 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/api.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Config } from '@backstage/config'; +import { UrlPatternDiscovery } from '@backstage/core'; import { TechDocsStorageApi } from './api'; -const DOC_STORAGE_URL = 'https://example-storage.com'; - const mockEntity = { kind: 'Component', namespace: 'default', @@ -24,19 +24,31 @@ const mockEntity = { }; describe('TechDocsStorageApi', () => { - it('should return correct base url based on defined storage', () => { - const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); + const mockBaseUrl = 'http://backstage:9191/api/techdocs'; + const configApi = { + getOptionalString: () => 'http://backstage:9191/api/techdocs', + } as Partial; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); - expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, + it('should return correct base url based on defined storage', async () => { + // @ts-ignore Partial not assignable to Config. + const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); + + await expect( + storageApi.getBaseUrl('test.js', mockEntity, ''), + ).resolves.toEqual( + `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, ); }); - it('should return base url with correct entity structure', () => { - const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); + it('should return base url with correct entity structure', async () => { + // @ts-ignore Partial not assignable to Config. + const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); - expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, + await expect( + storageApi.getBaseUrl('test/', mockEntity, ''), + ).resolves.toEqual( + `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, ); }); }); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index c8581ec5d3..7f77be44e2 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -14,8 +14,10 @@ * limitations under the License. */ -import { createApiRef } from '@backstage/core'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; +import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; +import { TechDocsMetadata } from './types'; export const techdocsStorageApiRef = createApiRef({ id: 'plugin.techdocs.storageservice', @@ -29,11 +31,15 @@ export const techdocsApiRef = createApiRef({ export interface TechDocsStorage { getEntityDocs(entityId: EntityName, path: string): Promise; - getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string; + getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise; } export interface TechDocs { - getTechDocsMetadata(entityId: EntityName): Promise; + getTechDocsMetadata(entityId: EntityName): Promise; getEntityMetadata(entityId: EntityName): Promise; } @@ -43,10 +49,25 @@ export interface TechDocs { * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API */ export class TechDocsApi implements TechDocs { - public apiOrigin: string; + public configApi: Config; + public discoveryApi: DiscoveryApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } /** @@ -61,7 +82,8 @@ export class TechDocsApi implements TechDocs { async getTechDocsMetadata(entityId: EntityName) { const { kind, namespace, name } = entityId; - const requestUrl = `${this.apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; const request = await fetch(`${requestUrl}`); const res = await request.json(); @@ -80,7 +102,8 @@ export class TechDocsApi implements TechDocs { async getEntityMetadata(entityId: EntityName) { const { kind, namespace, name } = entityId; - const requestUrl = `${this.apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; const request = await fetch(`${requestUrl}`); const res = await request.json(); @@ -95,10 +118,25 @@ export class TechDocsApi implements TechDocs { * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API */ export class TechDocsStorageApi implements TechDocsStorage { - public apiOrigin: string; + public configApi: Config; + public discoveryApi: DiscoveryApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } /** @@ -112,31 +150,47 @@ export class TechDocsStorageApi implements TechDocsStorage { async getEntityDocs(entityId: EntityName, path: string) { const { kind, namespace, name } = entityId; - const url = `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, ); - if (request.status === 404) { - let errorMessage = 'Page not found. '; - // path is empty for the home page of an entity's docs site - if (!path) { - errorMessage += - 'This could be because there is no index.md file in the root of the docs directory of this repository.'; - } - throw new Error(errorMessage); + let errorMessage = ''; + switch (request.status) { + case 404: + errorMessage = 'Page not found. '; + // path is empty for the home page of an entity's docs site + if (!path) { + errorMessage += + 'This could be because there is no index.md file in the root of the docs directory of this repository.'; + } + throw new Error(errorMessage); + case 500: + errorMessage = + 'Could not generate documentation or an error in the TechDocs backend. '; + throw new Error(errorMessage); + default: + // Do nothing + break; } return request.text(); } - getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string { + async getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise { const { kind, namespace, name } = entityId; + const apiOrigin = await this.getApiOrigin(); + return new URL( oldBaseUrl, - `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`, + `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`, ).toString(); } } diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index 1726e0daf3..30c143e31f 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + techdocsPlugin, + techdocsPlugin as plugin, + TechdocsPage, + EntityTechdocsContent, +} from './plugin'; export { Router, EmbeddedDocsRouter } from './Router'; export * from './reader'; export * from './api'; diff --git a/plugins/techdocs/src/plugin.test.ts b/plugins/techdocs/src/plugin.test.ts index e750be9ac3..52f072627c 100644 --- a/plugins/techdocs/src/plugin.test.ts +++ b/plugins/techdocs/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { techdocsPlugin } from './plugin'; describe('techdocs', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(techdocsPlugin).toBeDefined(); }); }); diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 576ba0a292..0b4063c237 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -34,6 +34,8 @@ import { createRouteRef, createApiFactory, configApiRef, + discoveryApiRef, + createRoutableExtension, } from '@backstage/core'; import { techdocsStorageApiRef, @@ -57,25 +59,44 @@ export const rootCatalogDocsRouteRef = createRouteRef({ title: 'Docs', }); -// TODO: Use discovery API for frontend to get URL for techdocs-backend instead of requestUrl -export const plugin = createPlugin({ +export const techdocsPlugin = createPlugin({ id: 'techdocs', apis: [ createApiFactory({ api: techdocsStorageApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => new TechDocsStorageApi({ - apiOrigin: configApi.getString('techdocs.requestUrl'), + configApi, + discoveryApi, }), }), createApiFactory({ api: techdocsApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => new TechDocsApi({ - apiOrigin: configApi.getString('techdocs.requestUrl'), + configApi, + discoveryApi, }), }), ], + routes: { + root: rootRouteRef, + entityContent: rootCatalogDocsRouteRef, + }, }); + +export const TechdocsPage = techdocsPlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); + +export const EntityTechdocsContent = techdocsPlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.EmbeddedDocsRouter), + mountPoint: rootCatalogDocsRouteRef, + }), +); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index fb49b90096..c651f1ec13 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -115,13 +115,21 @@ export const Reader = ({ entityId, onReady }: Props) => { baseUrl: window.location.origin, onClick: (_: MouseEvent, url: string) => { const parsedUrl = new URL(url); - navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); + if (parsedUrl.hash) { + history.pushState( + null, + '', + `${parsedUrl.pathname}${parsedUrl.hash}`, + ); + } else { + navigate(parsedUrl.pathname); + } shadowRoot?.querySelector(parsedUrl.hash)?.scrollIntoView(); }, }), onCssReady({ - docStorageUrl: techdocsStorageApi.apiOrigin, + docStorageUrl: techdocsStorageApi.getApiOrigin(), onLoading: (dom: Element) => { (dom as HTMLElement).style.setProperty('opacity', '0'); }, @@ -147,7 +155,9 @@ export const Reader = ({ entityId, onReady }: Props) => { ]); if (error) { - return ; + // TODO Enhance API call to return customize error objects so we can identify which we ran into + // For now this defaults to display error code 404 + return ; } return ( diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx index 0924b90fc1..fb951f0a19 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import { ApiProvider, ApiRegistry } from '@backstage/core-api'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index 9f569dd167..d1841c3bba 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -15,23 +15,24 @@ */ import { + CodeSnippet, Content, Header, ItemCard, Page, Progress, useApi, + WarningPanel, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Grid } from '@material-ui/core'; import React from 'react'; -import { generatePath, useNavigate } from 'react-router-dom'; +import { generatePath } from 'react-router-dom'; import { useAsync } from 'react-use'; import { rootDocsRouteRef } from '../../plugin'; export const TechDocsHome = () => { const catalogApi = useApi(catalogApiRef); - const navigate = useNavigate(); const { value, loading, error } = useAsync(async () => { const response = await catalogApi.getEntities(); @@ -62,7 +63,12 @@ export const TechDocsHome = () => { subtitle="Documentation available in Backstage" /> -

{error.message}

+ + +
); @@ -80,15 +86,11 @@ export const TechDocsHome = () => { ? value.map((entity, index: number) => ( - navigate( - generatePath(rootDocsRouteRef.path, { - namespace: entity.metadata.namespace ?? 'default', - kind: entity.kind, - name: entity.metadata.name, - }), - ) - } + href={generatePath(rootDocsRouteRef.path, { + namespace: entity.metadata.namespace ?? 'default', + kind: entity.kind, + name: entity.metadata.name, + })} title={entity.metadata.name} label="Read Docs" description={entity.metadata.description} diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx index d3c98fec78..c6562ec8ad 100644 --- a/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx @@ -41,3 +41,20 @@ describe('', ( expect(rendered.getByTestId('go-back-link')).toBeDefined(); }); }); + +describe('', () => { + it('should render with a custom status code, custom error message and go back link', () => { + const rendered = render( + wrapInTestApp( + , + ), + ); + rendered.getByText(/This is a custom error message/i); + rendered.getByText(/500/i); + rendered.getByText(/Looks like someone dropped the mic!/i); + expect(rendered.getByTestId('go-back-link')).toBeDefined(); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx index cdacc1cb7e..04f2106161 100644 --- a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx @@ -19,9 +19,10 @@ import { ErrorPage, useApi, configApiRef } from '@backstage/core'; type Props = { errorMessage?: string; + statusCode?: number; }; -export const TechDocsNotFound = ({ errorMessage }: Props) => { +export const TechDocsNotFound = ({ errorMessage, statusCode }: Props) => { const techdocsBuilder = useApi(configApiRef).getOptionalString( 'techdocs.builder', ); @@ -37,7 +38,7 @@ export const TechDocsNotFound = ({ errorMessage }: Props) => { return ( diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index c05c0488ff..4b578bef38 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { TechDocsPage } from './TechDocsPage'; import { render, act } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; -import { ApiRegistry, ApiProvider } from '@backstage/core-api'; +import { ApiRegistry, ApiProvider } from '@backstage/core'; import { techdocsApiRef, TechDocsApi, @@ -56,7 +56,8 @@ describe('', () => { }; const techdocsStorageApi: Partial = { getEntityDocs: (): Promise => Promise.resolve('String'), - getBaseUrl: (): string => '', + getBaseUrl: (): Promise => Promise.resolve('String'), + getApiOrigin: (): Promise => Promise.resolve('String'), }; const apiRegistry = ApiRegistry.from([ diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx index ac9d21d319..c994b52422 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -19,12 +19,13 @@ import { AsyncState } from 'react-use/lib/useAsync'; import CodeIcon from '@material-ui/icons/Code'; import { EntityName } from '@backstage/catalog-model'; import { Header, HeaderLabel, Link } from '@backstage/core'; +import { TechDocsMetadata } from '../../types'; type TechDocsPageHeaderProps = { entityId: EntityName; metadataRequest: { entity: AsyncState; - techdocs: AsyncState; + techdocs: AsyncState; }; }; diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index 728c39826d..10c7191b63 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -21,7 +21,7 @@ import { TechDocsStorage } from '../../api'; const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; const techdocsStorageApi: TechDocsStorage = { - getBaseUrl: jest.fn(() => DOC_STORAGE_URL), + getBaseUrl: jest.fn(() => Promise.resolve(DOC_STORAGE_URL)), getEntityDocs: () => new Promise(resolve => resolve('yes!')), }; diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 8d9d5a7294..2872ad36cc 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -35,12 +35,12 @@ export const addBaseUrl = ({ ): void => { Array.from(list) .filter(elem => !!elem.getAttribute(attributeName)) - .forEach((elem: T) => { + .forEach(async (elem: T) => { const elemAttribute = elem.getAttribute(attributeName); if (!elemAttribute) return; elem.setAttribute( attributeName, - techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path), + await techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path), ); }); }; diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts index 67ae38c56b..1fbc59e213 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts @@ -22,8 +22,9 @@ import { } from '../../test-utils'; import { onCssReady } from '../transformers'; -const docStorageUrl: string = - 'https://techdocs-mock-sites.storage.googleapis.com'; +const docStorageUrl: Promise = Promise.resolve( + 'https://techdocs-mock-sites.storage.googleapis.com', +); const fixture = ` diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.ts b/plugins/techdocs/src/reader/transformers/onCssReady.ts index dd50879459..50dbe52cbd 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.ts @@ -17,7 +17,7 @@ import type { Transformer } from './index'; type OnCssReadyOptions = { - docStorageUrl: string; + docStorageUrl: Promise; onLoading: (dom: Element) => void; onLoaded: (dom: Element) => void; }; @@ -30,7 +30,9 @@ export const onCssReady = ({ return dom => { const cssPages = Array.from( dom.querySelectorAll('head > link[rel="stylesheet"]'), - ).filter(elem => elem.getAttribute('href')?.startsWith(docStorageUrl)); + ).filter(async elem => + elem.getAttribute('href')?.startsWith(await docStorageUrl), + ); let count = cssPages.length; diff --git a/plugins/techdocs/src/types.ts b/plugins/techdocs/src/types.ts new file mode 100644 index 0000000000..36cc4e187b --- /dev/null +++ b/plugins/techdocs/src/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 TechDocsMetadata = { + site_name: string; + site_description: string; +}; diff --git a/plugins/user-settings/CHANGELOG.md b/plugins/user-settings/CHANGELOG.md index f213193728..467f207754 100644 --- a/plugins/user-settings/CHANGELOG.md +++ b/plugins/user-settings/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/plugin-user-settings +## 0.2.5 + +### Patch Changes + +- bc5082a00: Migrate to new composability API, exporting the plugin as `userSettingsPlugin` and the page as `UserSettingsPage`. +- de98c32ed: Keep the Pin Sidebar setting visible on small screens. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [54c7d02f7] + - @backstage/core@0.6.0 + - @backstage/theme@0.2.3 + +## 0.2.4 + +### Patch Changes + +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] + - @backstage/core@0.5.0 + ## 0.2.3 ### Patch Changes diff --git a/plugins/user-settings/dev/index.tsx b/plugins/user-settings/dev/index.tsx index 264d6f801f..3eaed00022 100644 --- a/plugins/user-settings/dev/index.tsx +++ b/plugins/user-settings/dev/index.tsx @@ -13,7 +13,15 @@ * 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(); +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { userSettingsPlugin, UserSettingsPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(userSettingsPlugin) + .addPage({ + title: 'Settings', + element: , + }) + .render(); diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index d238242a65..88453e8efb 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-user-settings", - "version": "0.2.3", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/core": "^0.6.0", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,8 +41,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx index 5df4df4450..985a67189b 100644 --- a/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx +++ b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx @@ -21,7 +21,7 @@ import { Button, Typography } from '@material-ui/core'; const EXAMPLE = `import { createPlugin } from '@backstage/core'; export default createPlugin({ - id: 'welcome', + id: 'plugin-name', register({ router, featureFlags }) { featureFlags.register('enable-example-feature'); }, @@ -32,11 +32,11 @@ export const EmptyFlags = () => ( - An example how how to add a feature flags is highlighted below: + An example for how to add a feature flag is highlighted below: { - const theme = useTheme(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); return ( @@ -32,7 +30,7 @@ export const General = () => { - {!fullScreen && } + diff --git a/plugins/user-settings/src/components/General/Profile.tsx b/plugins/user-settings/src/components/General/Profile.tsx index b0231d95b6..c8034085bd 100644 --- a/plugins/user-settings/src/components/General/Profile.tsx +++ b/plugins/user-settings/src/components/General/Profile.tsx @@ -24,31 +24,27 @@ export const Profile = () => { const { profile, displayName } = useUserProfile(); return ( - - - - - - - - - - - - {displayName} - - - {profile.email} - - - - - - + + + + + + + + + + {displayName} + + + {profile.email} + - + + + + - + ); }; diff --git a/plugins/user-settings/src/index.ts b/plugins/user-settings/src/index.ts index 9b0ce4262c..b26c8b4b0b 100644 --- a/plugins/user-settings/src/index.ts +++ b/plugins/user-settings/src/index.ts @@ -13,5 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin } from './plugin'; + +export { + userSettingsPlugin, + userSettingsPlugin as plugin, + UserSettingsPage, +} from './plugin'; export * from './components/'; diff --git a/plugins/user-settings/src/plugin.test.ts b/plugins/user-settings/src/plugin.test.ts index 810a2cda12..479fd0ac61 100644 --- a/plugins/user-settings/src/plugin.test.ts +++ b/plugins/user-settings/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { plugin } from './plugin'; +import { userSettingsPlugin } from './plugin'; describe('user-settings', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(userSettingsPlugin).toBeDefined(); }); }); diff --git a/plugins/user-settings/src/plugin.ts b/plugins/user-settings/src/plugin.ts index 2f896c8197..36d2254c11 100644 --- a/plugins/user-settings/src/plugin.ts +++ b/plugins/user-settings/src/plugin.ts @@ -13,13 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; + +import { + createPlugin, + createRoutableExtension, + createRouteRef, +} from '@backstage/core'; export const settingsRouteRef = createRouteRef({ path: '/settings', title: 'Settings', }); -export const plugin = createPlugin({ +export const userSettingsPlugin = createPlugin({ id: 'user-settings', + routes: { + settingsPage: settingsRouteRef, + }, }); + +export const UserSettingsPage = userSettingsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/SettingsPage').then(m => m.SettingsPage), + mountPoint: settingsRouteRef, + }), +); diff --git a/plugins/welcome/CHANGELOG.md b/plugins/welcome/CHANGELOG.md index d0f29b14d0..594d30226a 100644 --- a/plugins/welcome/CHANGELOG.md +++ b/plugins/welcome/CHANGELOG.md @@ -1,5 +1,29 @@ # @backstage/plugin-welcome +## 0.2.5 + +### Patch Changes + +- 8dfdec613: Migrated to new composability API, exporting the plugin as `welcomePlugin` and the page as `WelcomePage`. +- Updated dependencies [12ece98cd] +- Updated dependencies [d82246867] +- Updated dependencies [c810082ae] +- Updated dependencies [5fa3bdb55] +- Updated dependencies [21e624ba9] +- Updated dependencies [da9f53c60] +- Updated dependencies [32c95605f] +- Updated dependencies [54c7d02f7] + - @backstage/core@0.6.0 + - @backstage/theme@0.2.3 + +## 0.2.4 + +### Patch Changes + +- Updated dependencies [efd6ef753] +- Updated dependencies [a187b8ad0] + - @backstage/core@0.5.0 + ## 0.2.3 ### Patch Changes diff --git a/plugins/welcome/README.md b/plugins/welcome/README.md index f0211c248a..c40a760c7c 100644 --- a/plugins/welcome/README.md +++ b/plugins/welcome/README.md @@ -1,5 +1,8 @@ # Title +> This plugin formerly provided the "homepage" of early alpha versions of Backstage. +> It is no longer in active use and in most cases can be removed from your instance. + Welcome to the welcome plugin! ## Sub-section 1 diff --git a/plugins/welcome/dev/index.tsx b/plugins/welcome/dev/index.tsx index 812a5585d4..b237812f97 100644 --- a/plugins/welcome/dev/index.tsx +++ b/plugins/welcome/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { welcomePlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(welcomePlugin).render(); diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 21ee3787df..f3d50dc675 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.2.3", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,8 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.4.3", - "@backstage/theme": "^0.2.2", + "@backstage/core": "^0.6.0", + "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -41,8 +41,8 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.4", - "@backstage/dev-utils": "^0.1.7", + "@backstage/cli": "^0.6.0", + "@backstage/dev-utils": "^0.1.9", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/welcome/src/index.ts b/plugins/welcome/src/index.ts index 3a0a0fe2d3..cccc6bacf3 100644 --- a/plugins/welcome/src/index.ts +++ b/plugins/welcome/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { welcomePlugin, welcomePlugin as plugin, WelcomePage } from './plugin'; diff --git a/plugins/welcome/src/plugin.test.ts b/plugins/welcome/src/plugin.test.ts index d60c73ec68..381ea80f38 100644 --- a/plugins/welcome/src/plugin.test.ts +++ b/plugins/welcome/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { welcomePlugin } from './plugin'; describe('welcome', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(welcomePlugin).toBeDefined(); }); }); diff --git a/plugins/welcome/src/plugin.ts b/plugins/welcome/src/plugin.ts index 754f708110..f950b93874 100644 --- a/plugins/welcome/src/plugin.ts +++ b/plugins/welcome/src/plugin.ts @@ -14,18 +14,28 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import WelcomePage from './components/WelcomePage'; +import { + createPlugin, + createRoutableExtension, + createRouteRef, +} from '@backstage/core'; +import WelcomePageComponent from './components/WelcomePage'; export const rootRouteRef = createRouteRef({ - path: '/welcome', title: 'Welcome', }); -export const plugin = createPlugin({ +export const welcomePlugin = createPlugin({ id: 'welcome', register({ router, featureFlags }) { - router.addRoute(rootRouteRef, WelcomePage); + router.addRoute(rootRouteRef, WelcomePageComponent); featureFlags.register('enable-welcome-box'); }, }); + +export const WelcomePage = welcomePlugin.provide( + createRoutableExtension({ + component: () => import('./components/WelcomePage').then(m => m.default), + mountPoint: rootRouteRef, + }), +); diff --git a/scripts/check-if-release.js b/scripts/check-if-release.js new file mode 100755 index 0000000000..dcfc862e48 --- /dev/null +++ b/scripts/check-if-release.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/* eslint-disable import/no-extraneous-dependencies */ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This script is used to determine whether a particular commit has changes +// that should lead to a release. It is run as part of the main master build +// to determine whether the release flow should be run as well. +// +// It has the following output which can be used later in GitHub actions: +// +// needs_release = 'true' | 'false' + +const { execFile: execFileCb } = require('child_process'); +const { resolve: resolvePath } = require('path'); +const { promises: fs } = require('fs'); +const { promisify } = require('util'); + +const parentRef = process.env.COMMIT_SHA_BEFORE || 'HEAD^'; + +const execFile = promisify(execFileCb); + +async function runPlain(cmd, ...args) { + try { + const { stdout } = await execFile(cmd, args, { shell: true }); + return stdout.trim(); + } catch (error) { + if (error.stderr) { + process.stderr.write(error.stderr); + } + if (!error.code) { + throw error; + } + throw new Error( + `Command '${[cmd, ...args].join(' ')}' failed with code ${error.code}`, + ); + } +} + +async function main() { + process.cwd(resolvePath(__dirname, '..')); + + const diff = await runPlain( + 'git', + 'diff', + '--name-only', + parentRef, + "'packages/*/package.json'", + "'plugins/*/package.json'", + ); + const packageList = diff.split(/^(.*)$/gm).filter(s => s.trim()); + + const packageVersions = await Promise.all( + packageList.map(async path => { + let name; + let newVersion; + let oldVersion; + + try { + const data = JSON.parse( + await runPlain('git', 'show', `${parentRef}:${path}`), + ); + name = data.name; + oldVersion = data.version; + } catch { + oldVersion = ''; + } + + try { + const data = JSON.parse(await fs.readFile(path, 'utf8')); + name = data.name; + newVersion = data.version; + } catch (error) { + if (error.code === 'ENOENT') { + newVersion = ''; + } + } + + return { name, oldVersion, newVersion }; + }), + ); + + const newVersions = packageVersions.filter( + ({ oldVersion, newVersion }) => + oldVersion !== newVersion && newVersion !== '', + ); + + if (newVersions.length === 0) { + console.log('No package version bumps detected, no release needed'); + console.log(`::set-output name=needs_release::false`); + return; + } + + console.log('Package version bumps detected, a new release is needed'); + const maxLength = Math.max(...newVersions.map(_ => _.name.length)); + for (const { name, oldVersion, newVersion } of newVersions) { + console.log( + ` ${name.padEnd(maxLength, ' ')} ${oldVersion} to ${newVersion}`, + ); + } + console.log(`::set-output name=needs_release::true`); +} + +main().catch(error => { + console.error(error.stack); + process.exit(1); +}); diff --git a/scripts/create-github-release.js b/scripts/create-github-release.js index a9bfb9f5b3..1d6a493db3 100755 --- a/scripts/create-github-release.js +++ b/scripts/create-github-release.js @@ -53,7 +53,8 @@ if (!BOOL_CREATE_RELEASE) { const GH_OWNER = 'backstage'; const GH_REPO = 'backstage'; -const EXPECTED_COMMIT_MESSAGE = /^Merge pull request #(?[0-9]+) from backstage\/changeset-release\/master\n\nVersion Packages$/; +const EXPECTED_COMMIT_MESSAGE = /^Merge pull request #(?[0-9]+) from/; +const CHANGESET_RELEASE_BRANCH = 'backstage/changeset-release/master'; // Initialize a GitHub client const octokit = new Octokit({ @@ -116,7 +117,7 @@ async function getCommitMessageUsingTagName(tagName) { } // There is a PR number in our expected commit message. Get the description of that PR. -async function getPrDescriptionFromCommitMessage(commitMessage) { +async function getReleaseDescriptionFromCommitMessage(commitMessage) { // It should exactly match the pattern of changeset commit message, or else will abort. const expectedMessage = RegExp(EXPECTED_COMMIT_MESSAGE); if (!expectedMessage.test(commitMessage)) { @@ -131,20 +132,19 @@ async function getPrDescriptionFromCommitMessage(commitMessage) { `Identified the changeset Pull request - https://github.com/backstage/backstage/pull/${prNumber}`, ); - const prData = await octokit.pulls.get({ + const { data } = await octokit.pulls.get({ owner: GH_OWNER, repo: GH_REPO, pull_number: prNumber, }); - return prData.data.body; -} + // Use the PR description to prepare for the release description + const isChangesetRelease = commitMessage.includes(CHANGESET_RELEASE_BRANCH); + if (isChangesetRelease) { + return data.body.split('\n').slice(3).join('\n'); + } -// Use the PR description to prepare for the release description -async function prepareReleaseDescription(prDescription) { - // TODO: Refine prDescription to remove the lines containing "Update Dependencies" - // Remove everything in the beginning until changelogs. - return prDescription.split('\n').slice(3).join('\n'); + return data.body; } // Create Release on GitHub. @@ -178,8 +178,9 @@ async function createRelease(releaseDescription) { async function main() { const commitMessage = await getCommitMessageUsingTagName(TAG_NAME); - const prDescription = await getPrDescriptionFromCommitMessage(commitMessage); - const releaseDescription = await prepareReleaseDescription(prDescription); + const releaseDescription = await getReleaseDescriptionFromCommitMessage( + commitMessage, + ); await createRelease(releaseDescription); } diff --git a/scripts/create-release-tag.js b/scripts/create-release-tag.js index 5c57c9b278..bcfe9153ff 100755 --- a/scripts/create-release-tag.js +++ b/scripts/create-release-tag.js @@ -35,9 +35,10 @@ async function main() { const octokit = new Octokit({ auth: GITHUB_TOKEN }); const date = new Date(); - const baseTagName = `release-${date.getUTCFullYear()}-${ - date.getUTCMonth() + 1 - }-${date.getUTCDate()}`; + const yyyy = date.getUTCFullYear(); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + const baseTagName = `release-${yyyy}-${mm}-${dd}`; console.log('Requesting existing tags'); diff --git a/scripts/verify-links.js b/scripts/verify-links.js index 75e7a8c1d0..5ad14ead4d 100755 --- a/scripts/verify-links.js +++ b/scripts/verify-links.js @@ -18,8 +18,27 @@ /* eslint-disable import/no-extraneous-dependencies */ const { resolve: resolvePath, join: joinPath, dirname } = require('path'); -const fs = require('fs-extra'); -const recursive = require('recursive-readdir'); +const fs = require('fs').promises; +const { existsSync } = require('fs'); + +const IGNORED_DIRS = ['node_modules', 'dist', 'bin', '.git']; + +async function listFiles(dir) { + const files = await fs.readdir(dir); + const paths = await Promise.all( + files + .filter(file => !IGNORED_DIRS.includes(file)) + .map(async file => { + const path = joinPath(dir, file); + + if ((await fs.stat(path)).isDirectory()) { + return listFiles(path); + } + return path; + }), + ); + return paths.flat(); +} const projectRoot = resolvePath(__dirname, '..'); @@ -65,7 +84,7 @@ async function verifyUrl(basePath, absUrl, docPages) { } const staticPath = resolvePath(projectRoot, 'microsite/static', `.${url}`); - if (await fs.pathExists(staticPath)) { + if (existsSync(staticPath)) { return undefined; } @@ -82,8 +101,7 @@ async function verifyUrl(basePath, absUrl, docPages) { return { url, basePath, problem: 'out-of-docs' }; } - const exists = await fs.pathExists(path); - if (!exists) { + if (!existsSync(path)) { return { url, basePath, problem: 'missing' }; } @@ -110,7 +128,7 @@ async function verifyFile(filePath, docPages) { // It is used to validate microsite links from outside /docs/, as those // are not transformed from the markdown file representation by docusaurus. async function findExternalDocsLinks(dir) { - const allFiles = await recursive(dir); + const allFiles = await listFiles(dir); const mdFiles = allFiles.filter(p => p.endsWith('.md')); const paths = new Map(); @@ -138,14 +156,15 @@ async function findExternalDocsLinks(dir) { async function main() { process.chdir(projectRoot); - const files = await recursive('.', ['node_modules', 'dist', 'bin']); + const files = await listFiles('.'); const mdFiles = files.filter(f => f.endsWith('.md')); const badUrls = []; const docPages = await findExternalDocsLinks('docs'); + const docPageSet = new Set(docPages.values()); for (const mdFile of mdFiles) { - const badFileUrls = await verifyFile(mdFile, new Set(docPages.values())); + const badFileUrls = await verifyFile(mdFile, docPageSet); badUrls.push(...badFileUrls); } diff --git a/yarn.lock b/yarn.lock index 3af2f2f713..05282e5285 100644 --- a/yarn.lock +++ b/yarn.lock @@ -68,10 +68,10 @@ dependencies: "@openapi-contrib/openapi-schema-to-json-schema" "^3.0.0" -"@asyncapi/parser@^1.0.1": - version "1.1.0" - resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.1.0.tgz#b366c85a6322e182e040d60f36400a0b5c26393c" - integrity sha512-0+NeTiW5sPNYaFf4P2VCcy7Z7MLMP7H969cgOp3gvVTKroI5idkYnWo/C16tKAxs+2oOQ9WnPolsNgc+jb/qtg== +"@asyncapi/parser@^1.3.2": + version "1.3.2" + resolved "https://registry.npmjs.org/@asyncapi/parser/-/parser-1.3.2.tgz#6ff22680ff0e9aaf732121040e01e1f5836a305d" + integrity sha512-YDGdxoVA3+gQ3jvWDp+J3IV5XsvAe+nSHSytPg9m5hfJ48R+mBPMnO7Bnh//gR17LjbmyebPj27h4Ceyaa4d3Q== dependencies: "@apidevtools/json-schema-ref-parser" "^9.0.6" "@asyncapi/specs" "^2.7.5" @@ -82,29 +82,1005 @@ node-fetch "^2.6.0" tiny-merge-patch "^0.1.2" +"@asyncapi/react-component@^0.18.2": + version "0.18.2" + resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-0.18.2.tgz#74882b4c2a5ecd00c156898f38d0a5c9e8c4384d" + integrity sha512-vsD3acSM0hTxxSsTJoU79kpI9FXVA64OqNCu8xrawfaU4wdsSQWWn0y7SD8pUfwe4gohwmms0xNcAaMzYIhGLw== + dependencies: + "@asyncapi/avro-schema-parser" "^0.2.0" + "@asyncapi/openapi-schema-parser" "^2.0.0" + "@asyncapi/parser" "^1.3.2" + constate "^1.2.0" + dompurify "^2.1.1" + markdown-it "^11.0.1" + merge "^2.1.0" + openapi-sampler "^1.0.0-beta.15" + react-use "^12.2.0" + "@asyncapi/specs@^2.7.5": version "2.7.5" resolved "https://registry.npmjs.org/@asyncapi/specs/-/specs-2.7.5.tgz#3a516d198fc41a1103695bd889fdd4fbbebe7f5d" integrity sha512-T1Ham9sqZKCtSowXRPaBCRy2oz3KHglqqrKiaO7lEudpP6lwH5SwXaq4qliyKzWaqd22srJHE4szdsorbFZKlw== -"@azure/msal-common@^1.6.2": - version "1.6.2" - resolved "https://registry.npmjs.org/@azure/msal-common/-/msal-common-1.6.2.tgz#91f3732866d727e20f1e142e6e88a981268fbff2" - integrity sha512-GShzp1q7Ld8SwYiDEjQZ9PmFOY4x+2stE86maiguylE9/d/c2muqKjc8aepmEqyjbV7o/omDvEf2Sr9QcIqkSA== +"@aws-crypto/crc32@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-1.0.0.tgz#6a0164fd92bb365860ba6afb5dfef449701eb8ca" + integrity sha512-wr4EyCv3ZfLH3Sg7FErV6e/cLhpk9rUP/l5322y8PRgpQsItdieaLbtE4aDOR+dxl8U7BG9FIwWXH4TleTDZ9A== + dependencies: + tslib "^1.11.1" + +"@aws-crypto/ie11-detection@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-1.0.0.tgz#d3a6af29ba7f15458f79c41d1cd8cac3925e726a" + integrity sha512-kCKVhCF1oDxFYgQrxXmIrS5oaWulkvRcPz+QBDMsUr2crbF4VGgGT6+uQhSwJFdUAQ2A//Vq+uT83eJrkzFgXA== + dependencies: + tslib "^1.11.1" + +"@aws-crypto/sha256-browser@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-1.0.0.tgz#9c34d3b829d922b2c8e077b30a60db53d6befcb1" + integrity sha512-uSufui4ZktC5lYX6bDxgBgNboxGyw9V9V+rlcNsNTxh4YPhOdCslwJMGntiWOBRGAgXhhvWi7FqnTS2SaT3cpg== + dependencies: + "@aws-crypto/ie11-detection" "^1.0.0" + "@aws-crypto/sha256-js" "^1.0.0" + "@aws-crypto/supports-web-crypto" "^1.0.0" + "@aws-sdk/types" "^1.0.0-rc.1" + "@aws-sdk/util-locate-window" "^1.0.0-rc.1" + "@aws-sdk/util-utf8-browser" "^1.0.0-rc.1" + tslib "^1.11.1" + +"@aws-crypto/sha256-js@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-1.0.0.tgz#ca788a3242a4024c386e6b9985da28f290a79ad7" + integrity sha512-89kqtFs/tdHBFHEBXZ4UXlCISswvEor3BVVOriR68Tbk1Qe1zBOZtfbSOt3CDT69z88x5uM558YW9k8I1xei5A== + dependencies: + "@aws-sdk/types" "^1.0.0-rc.1" + "@aws-sdk/util-utf8-browser" "^1.0.0-rc.1" + tslib "^1.11.1" + +"@aws-crypto/supports-web-crypto@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-1.0.0.tgz#c40901bc17ac1e875e248df16a2b47ad8bfd9a93" + integrity sha512-IHLfv+WmVH89EW4n6a5eE8/hUlz6qkWGMn/v4r5ZgzcXdTC5nolii2z3k46y01hWRiC2PPhOdeSLzMUCUMco7g== + dependencies: + tslib "^1.11.1" + +"@aws-sdk/abort-controller@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.1.0.tgz#6b1e6cfe7410ff38ff5efdfc2e16c309d7c4e233" + integrity sha512-rqE/+BBEvF4ZxT+J1hqaSDUzXTcdnyeHQ7vqEyB1UuAmMcq6nIi1rAQKqBXneaIC4WVER3K5f00NKZGLzzqnRg== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/chunked-blob-reader-native@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/chunked-blob-reader-native/-/chunked-blob-reader-native-3.1.0.tgz#49573fe4087f07894deef7a1bf184517c3fb8f24" + integrity sha512-ghBtZkhUWgy51/651l/GUR/qhdqjFR3GSCsz0B7qisrXc8ZNsd7OlXfnTfYNoySxD3XKpbcxsncytH4Hkxgi4A== + dependencies: + "@aws-sdk/util-base64-browser" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/chunked-blob-reader@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/chunked-blob-reader/-/chunked-blob-reader-3.1.0.tgz#0a17272040760ce54ee6cd5b7e56efaca81e07aa" + integrity sha512-/2fxbKwta8ZiSj59B8F3FyVRszo1/VOhpCeL16gmRRNV73rM3IqJD+xOaDdkc/sFYyBeWn/UhwgD98kxae9XsQ== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/client-organizations@^3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/client-organizations/-/client-organizations-3.2.0.tgz#f9fc15ecc61fdf31d1cc1eb5590e526b60bc83c0" + integrity sha512-zIFHW5tETCeoSkmLh227eG25B4HnngVDktXhbPmp1NJjT2pcZzL5QIXuNcPz3PTbkaDNRdB04uRdDqryK4kY/g== + dependencies: + "@aws-crypto/sha256-browser" "^1.0.0" + "@aws-crypto/sha256-js" "^1.0.0" + "@aws-sdk/config-resolver" "3.2.0" + "@aws-sdk/credential-provider-node" "3.1.0" + "@aws-sdk/fetch-http-handler" "3.2.0" + "@aws-sdk/hash-node" "3.1.0" + "@aws-sdk/invalid-dependency" "3.2.0" + "@aws-sdk/middleware-content-length" "3.2.0" + "@aws-sdk/middleware-host-header" "3.2.0" + "@aws-sdk/middleware-logger" "3.2.0" + "@aws-sdk/middleware-retry" "3.2.0" + "@aws-sdk/middleware-serde" "3.2.0" + "@aws-sdk/middleware-signing" "3.2.0" + "@aws-sdk/middleware-stack" "3.1.0" + "@aws-sdk/middleware-user-agent" "3.2.0" + "@aws-sdk/node-config-provider" "3.1.0" + "@aws-sdk/node-http-handler" "3.2.0" + "@aws-sdk/protocol-http" "3.2.0" + "@aws-sdk/smithy-client" "3.2.0" + "@aws-sdk/url-parser-browser" "3.1.0" + "@aws-sdk/url-parser-node" "3.1.0" + "@aws-sdk/util-base64-browser" "3.1.0" + "@aws-sdk/util-base64-node" "3.1.0" + "@aws-sdk/util-body-length-browser" "3.1.0" + "@aws-sdk/util-body-length-node" "3.1.0" + "@aws-sdk/util-user-agent-browser" "3.2.0" + "@aws-sdk/util-user-agent-node" "3.2.0" + "@aws-sdk/util-utf8-browser" "3.1.0" + "@aws-sdk/util-utf8-node" "3.1.0" + tslib "^2.0.0" + +"@aws-sdk/client-s3@^3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1.0.tgz#9c39f4588420696e1a4920f8315f778a7896b858" + integrity sha512-RX/eBi3mHoJGkS146tA4ScSdOwSLEkHWlETFkcqVYdH6IP5FT9CiwRqSf6aNRE+LQiZXoBxeOdvGFXg2jWg83w== + dependencies: + "@aws-crypto/sha256-browser" "^1.0.0" + "@aws-crypto/sha256-js" "^1.0.0" + "@aws-sdk/config-resolver" "3.1.0" + "@aws-sdk/credential-provider-node" "3.1.0" + "@aws-sdk/eventstream-serde-browser" "3.1.0" + "@aws-sdk/eventstream-serde-config-resolver" "3.1.0" + "@aws-sdk/eventstream-serde-node" "3.1.0" + "@aws-sdk/fetch-http-handler" "3.1.0" + "@aws-sdk/hash-blob-browser" "3.1.0" + "@aws-sdk/hash-node" "3.1.0" + "@aws-sdk/hash-stream-node" "3.1.0" + "@aws-sdk/invalid-dependency" "3.1.0" + "@aws-sdk/md5-js" "3.1.0" + "@aws-sdk/middleware-apply-body-checksum" "3.1.0" + "@aws-sdk/middleware-bucket-endpoint" "3.1.0" + "@aws-sdk/middleware-content-length" "3.1.0" + "@aws-sdk/middleware-expect-continue" "3.1.0" + "@aws-sdk/middleware-host-header" "3.1.0" + "@aws-sdk/middleware-location-constraint" "3.1.0" + "@aws-sdk/middleware-logger" "3.1.0" + "@aws-sdk/middleware-retry" "3.1.0" + "@aws-sdk/middleware-sdk-s3" "3.1.0" + "@aws-sdk/middleware-serde" "3.1.0" + "@aws-sdk/middleware-signing" "3.1.0" + "@aws-sdk/middleware-ssec" "3.1.0" + "@aws-sdk/middleware-stack" "3.1.0" + "@aws-sdk/middleware-user-agent" "3.1.0" + "@aws-sdk/node-config-provider" "3.1.0" + "@aws-sdk/node-http-handler" "3.1.0" + "@aws-sdk/protocol-http" "3.1.0" + "@aws-sdk/smithy-client" "3.1.0" + "@aws-sdk/url-parser-browser" "3.1.0" + "@aws-sdk/url-parser-node" "3.1.0" + "@aws-sdk/util-base64-browser" "3.1.0" + "@aws-sdk/util-base64-node" "3.1.0" + "@aws-sdk/util-body-length-browser" "3.1.0" + "@aws-sdk/util-body-length-node" "3.1.0" + "@aws-sdk/util-user-agent-browser" "3.1.0" + "@aws-sdk/util-user-agent-node" "3.1.0" + "@aws-sdk/util-utf8-browser" "3.1.0" + "@aws-sdk/util-utf8-node" "3.1.0" + "@aws-sdk/util-waiter" "3.1.0" + "@aws-sdk/xml-builder" "3.1.0" + fast-xml-parser "^3.16.0" + tslib "^2.0.0" + +"@aws-sdk/config-resolver@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.1.0.tgz#36987002c18884847aa1c96e0daf546b5f9caff6" + integrity sha512-/8hNlmeUPd1Ey9WqaZsydjWpDh1rcZpzOiiIhszOj3gGhUhsGsYGqP5oCwR9vMJf0Z5+o9diGT1QLMy9Rno6pw== + dependencies: + "@aws-sdk/signature-v4" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/config-resolver@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.2.0.tgz#c83de904f155282c91e0972b5053937ea1f05160" + integrity sha512-+PRjXpWq8JlN/ilO2F5sh/HAOkoCJkGnu/e8rfnND88tghVVauFPm7xTR2LHOGp1ugpUJm8lG/iEzpYP1EUcpA== + dependencies: + "@aws-sdk/signature-v4" "3.2.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-env@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.1.0.tgz#eaa3ed04960faafd4536e9fbb051d5007985ba03" + integrity sha512-WUSrtg/on6bP6rjOPdjhFMkPZgDoFZZL2FST4y9K6TkRxcDHejgPZmP13L74RBGIOYlcflZAxE/936OpzpsAAA== + dependencies: + "@aws-sdk/property-provider" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-env@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.3.0.tgz#7930e504a7a79ab98a9fd34befc5c84b8c4df679" + integrity sha512-kyqZMlGdH/05IhuXLBUXtj5+hhRfYiHFcJLc3ts/uiwCixswVHPAYHgyWm9ajFkmWtpz6ih+0LoYryhPbYu01A== + dependencies: + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-imds@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.1.0.tgz#33d48753dc00bddce79d2aa8076a7cb5bf8562df" + integrity sha512-I8P0ASEjuYpcDMfU2QKlT8vE3nIo803ct4y5Q54Osh5K7+H8Y+raYK9mxxhjycriDXhcQrOe2Rfj+1ARXjdvGw== + dependencies: + "@aws-sdk/property-provider" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-imds@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.3.0.tgz#ff0cf5489853c16d23fc99d7bae425587e836c40" + integrity sha512-Cx0YMnO/ScGQVDns006bLbqOxNURGN2Xm21bCY0l0ZUJCdJ2va1/9q1rljDyw2KvdzZNQVRQII3uUgj/Oq/K+g== + dependencies: + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-ini@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.1.0.tgz#1a6cf9ab9fa1450d4472b9e371099b0c0283349b" + integrity sha512-6kPFcsnCR1tEaVQPJGY6z58XvbeVtjsdC2srYG/0y87zyWd9awGuvRN4OV6aOr24QmEVaFNyWuE5QBQJ1/7hkA== + dependencies: + "@aws-sdk/property-provider" "3.1.0" + "@aws-sdk/shared-ini-file-loader" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-ini@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.3.0.tgz#55fe8f391b72d30e650ba8bc680e82bbeacbbfe5" + integrity sha512-zawNFJoasXiaV5n0H3/KNOi7mAZ7mHpG1+nBEkoWhZ31lIUM9+heGPcxKCbf/pMQjiOebUqL1OpWe4uSWxIVMw== + dependencies: + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/shared-ini-file-loader" "3.1.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-node@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.1.0.tgz#89bc8803752a3e580c6f2410306c7edad6be7fa2" + integrity sha512-/HNq75cNiXzxm6F+ZeJ3awuqUofUtcr89ZnSnwB1pkicz81yrqAH8EN2dEj90eDo1MdBQHx6rhaKg4SJUaBy3Q== + dependencies: + "@aws-sdk/credential-provider-env" "3.1.0" + "@aws-sdk/credential-provider-imds" "3.1.0" + "@aws-sdk/credential-provider-ini" "3.1.0" + "@aws-sdk/credential-provider-process" "3.1.0" + "@aws-sdk/property-provider" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-node@^3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.3.0.tgz#5c97323fa7b23590070d06aa7b1be8d93b2bf4be" + integrity sha512-PPBNzPq8fHk9dEQTTE4iJi6ZWtmo057Lc+I8Rlzmvz6NthK9iKiU819tfaxVBb6ZR7bLP0BuDiCi4G1lD+rQnQ== + dependencies: + "@aws-sdk/credential-provider-env" "3.3.0" + "@aws-sdk/credential-provider-imds" "3.3.0" + "@aws-sdk/credential-provider-ini" "3.3.0" + "@aws-sdk/credential-provider-process" "3.3.0" + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-process@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.1.0.tgz#ff817b29a9760c463b77be3ce49375eaeb753ef3" + integrity sha512-pecJk5W9LYLk9KOwT5A3X6ECHoakQMvXL/whqDsKdXQ4c5C1pVoV67Jnp3ilMNfr6CauxU8gQs53iPS5LUHH4A== + dependencies: + "@aws-sdk/credential-provider-ini" "3.1.0" + "@aws-sdk/property-provider" "3.1.0" + "@aws-sdk/shared-ini-file-loader" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/credential-provider-process@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.3.0.tgz#9de0984bd6dd0f5e40cff3672d7dd19e8cd43074" + integrity sha512-7oOF1j6ydUq43P3SsasiIpbxMKCmT0C+XwggHTGiVxNtX+QZiH1vdMf8otA7puLEey0iY5wTAIEcZhC6HenojA== + dependencies: + "@aws-sdk/credential-provider-ini" "3.3.0" + "@aws-sdk/property-provider" "3.3.0" + "@aws-sdk/shared-ini-file-loader" "3.1.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/eventstream-marshaller@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/eventstream-marshaller/-/eventstream-marshaller-3.1.0.tgz#65a217e37abcaa162276ccb1d4487d42431d1534" + integrity sha512-ZfWK+QPB+nuKfd90ZWpkJtK4wSKYv5qzA59jP1wwEWZ2XzUyTs2FWq4rns4af2UHnd+r5+92V2r/AZUCzT9U8w== + dependencies: + "@aws-crypto/crc32" "^1.0.0" + "@aws-sdk/util-hex-encoding" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/eventstream-serde-browser@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/eventstream-serde-browser/-/eventstream-serde-browser-3.1.0.tgz#e62f745ecb5336a957fe645f6d596501051ab73a" + integrity sha512-a7P8JzJhHTFAc6sY2uBVMr8RpLap58LXOgWUEQmozFOK6Klu2NxsdzfNGJAURsc1tK6cw126fidA9UMH/NR20Q== + dependencies: + "@aws-sdk/eventstream-marshaller" "3.1.0" + "@aws-sdk/eventstream-serde-universal" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/eventstream-serde-config-resolver@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.1.0.tgz#f4f95c4770ea34b06d79ef61eb8cbd73ac8403bc" + integrity sha512-OxgQNKNKOuXAy4ID77EC7glHALGOGdDt+fDteeajEEZ+XXQ2q4maIAbQ8N44bZcy4E8D20YEesaHDAtsDiUvPA== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/eventstream-serde-node@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/eventstream-serde-node/-/eventstream-serde-node-3.1.0.tgz#23d6630385d7023d3da9c905174ee391b5d9e5d6" + integrity sha512-xn3SwHhi9UDi6gE9fQxD8j+fxRzfnMWiu688sneoGSSzZl4e5lZtrRbMGWKdtT840dp+GrEXE6BVobUelOyVHA== + dependencies: + "@aws-sdk/eventstream-marshaller" "3.1.0" + "@aws-sdk/eventstream-serde-universal" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/eventstream-serde-universal@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/eventstream-serde-universal/-/eventstream-serde-universal-3.1.0.tgz#c48f51c92711cf0ec3a8e93b466d4dde8128d936" + integrity sha512-dnrsnLqBnFddxLtFGzDE9SBNl7+xJmgsL7SI9ALqxgvJkK7MVVj9+kzCEyypNHcRCG6OUTypvY+wjgMXJAtHUg== + dependencies: + "@aws-sdk/eventstream-marshaller" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/fetch-http-handler@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.1.0.tgz#2e495cbd5a633c3a5f9935b59faa9c8c0ed8b5e2" + integrity sha512-QCYGCdQuV3XLgqvKoJD3C/GO+cEZQOazII/rPfaeVXquv8Xrn85UJ3PZ3uFCzVVmjnm1RidwseRQoYrSsjmRhQ== + dependencies: + "@aws-sdk/protocol-http" "3.1.0" + "@aws-sdk/querystring-builder" "3.1.0" + "@aws-sdk/util-base64-browser" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/fetch-http-handler@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.2.0.tgz#116bfc06b3de797d4c746962874ef00e66fda024" + integrity sha512-Y8cw7+HNhxSpYZmd78Bq46rYUUCqnRH9AgHXkLifsigt4RsEAfYhODhEUQhEhJ/zwgadvj/fIKJWKdSiCbiC1A== + dependencies: + "@aws-sdk/protocol-http" "3.2.0" + "@aws-sdk/querystring-builder" "3.1.0" + "@aws-sdk/util-base64-browser" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/hash-blob-browser@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/hash-blob-browser/-/hash-blob-browser-3.1.0.tgz#5c6548b05d155d3141b47da958be13043a96a17f" + integrity sha512-A/iLk2P/15G//YPlB9DwNPR3J3QlrmJSrHKKYLj/t1DnxiIu3DZNCZjhLmBuQpx14O1GcolmewqJRaAq1qtVcw== + dependencies: + "@aws-sdk/chunked-blob-reader" "3.1.0" + "@aws-sdk/chunked-blob-reader-native" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/hash-node@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.1.0.tgz#2d9eb6c0a5b65c179d2e60db941ba921a83e1861" + integrity sha512-zcXjzPuraq6EEEC0N2DI/ng554XiHtLsWGYK2TkW5yS6+2xXiJFny3V5wZHWXsidHOgwgML92r/pU0PeVcTCbg== + dependencies: + "@aws-sdk/util-buffer-from" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/hash-stream-node@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/hash-stream-node/-/hash-stream-node-3.1.0.tgz#05424ef3873be626091b55617fe1df135ee34dcd" + integrity sha512-GQ7EsdH9yIYIVPhtcuwpYaTS3MNq+3jo6mUF06m8YxlG58GNIVz9gG8oMy9ezP5rpIjJX9YhXKN8GgERNxfpvQ== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/invalid-dependency@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.1.0.tgz#834c4e4a34d481bd3d4467437441ab035634710d" + integrity sha512-PNKFfIC9w25MA/fV5FnjFfFpbNiLaojgS2RtT/+L5pyic2NJ3spGNlF0pMrnPPtbrFFHaOy/E7bRa0qVtNWEyw== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/invalid-dependency@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.2.0.tgz#2c2c19cbec17ff28333c68e28420db6902a4374b" + integrity sha512-8hlqE4pWo2DoM6pAqjL+A3zYwUMaxP/RCGO+0zKbZHVw2stdRDzpO+jUfzZHR+mPJ+L/2wPwhK8D9n3yBriNBQ== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/is-array-buffer@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.1.0.tgz#7ac296d6408e34083ac007630541a1cdf67387fd" + integrity sha512-wE6Am+/FKuINc/aypXiBiLAatlSyxYQ9wGGQHf2iYOX5d5bHLOVKPoRwcqSCaiaR32aRcS7R+IhgxeBy+ajsMQ== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/md5-js@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/md5-js/-/md5-js-3.1.0.tgz#ae0799f9d9eeab01f4dd72baaa6bbb63cd45ffed" + integrity sha512-QB8D7EdxjOih9NWL/NoJRkgE0RSSJTXQLuQGH9UMj66rUQfGpwvLJDL+PbvoSUJgM8+McEBGyvjLCrDCK5r+qA== + dependencies: + "@aws-sdk/util-utf8-browser" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-apply-body-checksum@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-apply-body-checksum/-/middleware-apply-body-checksum-3.1.0.tgz#85504e7e7883830ffa06954a07f57b1fdb003408" + integrity sha512-q1Jx8HhC7fQjfrCUjbdlRw5F0Vr+gwjHf4sKd+S+FYO+15+PVtukBiKfP3lEW4KKREjTv1h/qPEjhv+CK4nN7w== + dependencies: + "@aws-sdk/is-array-buffer" "3.1.0" + "@aws-sdk/protocol-http" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-bucket-endpoint@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.1.0.tgz#38fb980fe99c42ddc264db40b93f28f84b3cc1e2" + integrity sha512-/NZuSbqSNARgsZqrRZ8yDXGqF0MSdA/vhNwXAk8BVz/oav4fveJ4KiMMO7Pq5918ZWJ3v//4bSezTnKLDzKmAw== + dependencies: + "@aws-sdk/protocol-http" "3.1.0" + "@aws-sdk/util-arn-parser" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-content-length@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.1.0.tgz#af100e6f724a188bb2963372de8d40868905be51" + integrity sha512-+QGIYkAch0q3a8LrbCS21MEKlE0WgtZBOJl76M3LZtvnbvgKkAmL4oDc+HjGD1jYuNRSt+hQZgqofuBDe8fyyw== + dependencies: + "@aws-sdk/protocol-http" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-content-length@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.2.0.tgz#876943c7498e930595621395e5928fc59cf8ed68" + integrity sha512-xS7zdcRcubv7WKs5L/RG69EuuatuPxYNQT4O9epQyUaOpTHKzdVd1wnBK+otWYxI9qnCjIXwwmMyGbJXD9OHhw== + dependencies: + "@aws-sdk/protocol-http" "3.2.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-expect-continue@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.1.0.tgz#3453aeb345ea852ebeefe392f7706b7573dd3c23" + integrity sha512-NCuF3BMf5QqXv4iYr4eGnmg7BfIpFjPvjxapB5cQbzUOXIyUtlYWUu7QrsdBxCsqgOhVJ4mAiqliVsdxML5yZA== + dependencies: + "@aws-sdk/middleware-header-default" "3.1.0" + "@aws-sdk/protocol-http" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-header-default@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-header-default/-/middleware-header-default-3.1.0.tgz#0b41d113f878d95132e10a48ca4c9667c28cf325" + integrity sha512-BJFQXxkkuIkN679bqqTo9hBUuTFtO4izzSY3WPnnYYcQwYTIH/4JSifCpJWw+AjbvTZ2rA2DrWaTNGz3zuiBGA== + dependencies: + "@aws-sdk/protocol-http" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-host-header@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.1.0.tgz#61f22f7d7ce8907d0dbc817ff67ec5545012dc51" + integrity sha512-x86fAcqhtK6ObgPJHFLRaZP+cC4gI185QwxqP72balLgIsJJ6InS7441sOMPr5AZ9xgLJyPPKXouQ5zFBYjwpw== + dependencies: + "@aws-sdk/protocol-http" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-host-header@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.2.0.tgz#cf5d0868f939834fb45c4490c1118eaf25317505" + integrity sha512-URRRaqO74DSdhqYyKbyCyadJ9No17KBMmgwz2OKecdwp6/pUVNQmlxhj1cG9leWUkBuOHo6weRvWc5zXwvfy1Q== + dependencies: + "@aws-sdk/protocol-http" "3.2.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-location-constraint@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.1.0.tgz#181007815ca9167b333666eb073b131e17d86fe7" + integrity sha512-NR8us/ZVJlxg/10h+dyu6xnKofSBWTp+Ai4yvkEFBsxr9kruVqGMP/2IFXl6iZAY5uy/H+TUJzyYtd4/DUgNgg== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/middleware-logger@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.1.0.tgz#6356f6d79524493a0485bfd86f621d7a882daa1a" + integrity sha512-RYJbms7ECg1FgYmN/IyK9U9nzWZtUmt2ZPBunUqvab/ldjaXpAtJq3IYdl1E/rgbc0LSTRDjyGC4erMDOT8IJg== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/middleware-logger@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.2.0.tgz#9d4bf84fbf7a0ddc144e6c660f1b806a23a42a6e" + integrity sha512-vkex6bsYqafdvrLe4MXJDJrEfvRb5Z8z01lVlaEV2bzXFqw7WNuFVDRC80GE/YB+r2ds/n9QU4EYUSX9wewoJQ== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/middleware-retry@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.1.0.tgz#4df049c73121a83e19f5078e90a411b93a8cf7de" + integrity sha512-jNewsmLhuSHCtwpuQlsOuQ/Cig7BE4BIF7cfpWaczqYaG1F/rdTlAmb69ugg5pVby7ccj2jgA4aw+achjuzqSw== + dependencies: + "@aws-sdk/protocol-http" "3.1.0" + "@aws-sdk/service-error-classification" "3.1.0" + react-native-get-random-values "^1.4.0" + tslib "^1.8.0" + uuid "^3.0.0" + +"@aws-sdk/middleware-retry@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.2.0.tgz#72bf988d90d9b0192aa7b290fc340b9f2e57a193" + integrity sha512-roecjUD9di1kqyCewk8pGXcsiHyi8kte5Is5bo9kCPfqHhtDN9L/efOhC+rhT0N9nKplC4rZGE4aopxNhMbLtQ== + dependencies: + "@aws-sdk/protocol-http" "3.2.0" + "@aws-sdk/service-error-classification" "3.2.0" + react-native-get-random-values "^1.4.0" + tslib "^1.8.0" + uuid "^3.0.0" + +"@aws-sdk/middleware-sdk-s3@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.1.0.tgz#e602bde5b9b5abe33e9087f61cf0f0cae9157bad" + integrity sha512-qH2RBt9EdDIO/2PGl7/QyIt9C4WuY6kVfx/S1Bh26wSY9s11VU73CDanZtgNQwIiX20v8DaPlg5dp2hse7Nr7w== + dependencies: + "@aws-sdk/protocol-http" "3.1.0" + "@aws-sdk/util-arn-parser" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-serde@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.1.0.tgz#a80b96dc0008546d9e9e9bce7e9b1ec134edbc6d" + integrity sha512-vXdYlzeBoJCqe+xhlFE9J62EyREvU1MVC6p3m3QPtfXuYlQwRb8WhlFFDFrDdA7V2usBjx5aM67OZnapplyUfg== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/middleware-serde@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.2.0.tgz#aff46b0457f44738cdc542ea4bea01160d437300" + integrity sha512-dWTL5lIjosKCUDyaNtEolaGpo/KwoM1Zx4NdMUBLYBy9v0vKSnpRLPXxyM2EREVJYHTVrvm6UoW8dM+5RFiN7A== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/middleware-signing@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.1.0.tgz#d48c18f159f867ca5efe34c4993880e6820cc11e" + integrity sha512-3zNsonJ/nkBL8gUWr2v1vQ/NKIQQgSjmrxIa4xUxRcqOOO1qgZgjablxYHQlX07B6defhaOS2/p/EDmF+LfPqQ== + dependencies: + "@aws-sdk/protocol-http" "3.1.0" + "@aws-sdk/signature-v4" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-signing@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.2.0.tgz#2ad5288d6543281a7e704102f6d446acdb9efaaa" + integrity sha512-XkDu0lMkdlf95nE2aYyAFmXOzBD+noVebyB1dlJDQCZWNzGkefc+8Nez3lCgyd2+KuEyHXMmQsqZwnmygpRvLQ== + dependencies: + "@aws-sdk/protocol-http" "3.2.0" + "@aws-sdk/signature-v4" "3.2.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-ssec@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.1.0.tgz#af57795facc29673568277d1d9aa090e57d69669" + integrity sha512-RGHWxNEMyYHv3et23XjMVS+9HhJIPR+oO21h82Eur4WhA6u3EuiZ+TKuoXZ9DA5V2wVJvPg33NHi0/uu1mah5w== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/middleware-stack@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.1.0.tgz#31a69784ff31d70e1176d948f94294563b23b36e" + integrity sha512-lin0C0xPspT/orPMWWHMYG/7Z128NsSj6Khs4G6TH+2rIixXxQtHLen8H2dSPNIYXnLaxvtUDl5VuqjRt+s2Ow== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/middleware-user-agent@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.1.0.tgz#b35ce7fae8f672aa9922bc28082137fa8c2f0486" + integrity sha512-ghUD5ZhGdvPEctacO+INnQpdpQUV8N9hC9We7ftAE75PGBG/aB8qnVWYV8uKAxx8ruNAi+iU4j+4B4qLFNMx2A== + dependencies: + "@aws-sdk/protocol-http" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/middleware-user-agent@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.2.0.tgz#19e64961feb6a41721e3164538031e21858ad7ee" + integrity sha512-rME49AFlVZSwgxTLQo/5Ok012HRcg9xhSFK4oOEkPJiYyGedvsF+Qy6sz3VjQRL2SvUvoAp03Wqbxe/Keewdbw== + dependencies: + "@aws-sdk/protocol-http" "3.2.0" + tslib "^1.8.0" + +"@aws-sdk/node-config-provider@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.1.0.tgz#74b37f65faa90fa6b4acb640e0f215f50aaa4904" + integrity sha512-zbV5Va40elco6IBddbDmhMQDWazQxHAk4trWFj3MrH36rpWZzWwsRbgjgGv0CcvSy5zP6PLHjdcBPeHS/KmViQ== + dependencies: + "@aws-sdk/property-provider" "3.1.0" + "@aws-sdk/shared-ini-file-loader" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/node-http-handler@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.1.0.tgz#b7fdbc3a1928127b2eb2064177502e11324f7bb8" + integrity sha512-T1yLundJ4tPeIt9IaOk24kBNBCe54UhCXXrh/0SSZZ26DLINPV5mF22wZKxgMpvKTbtJ6vlag+DhJe/ldqJ2qQ== + dependencies: + "@aws-sdk/abort-controller" "3.1.0" + "@aws-sdk/protocol-http" "3.1.0" + "@aws-sdk/querystring-builder" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/node-http-handler@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.2.0.tgz#cf6b12b021d61933202522179e5bd5710e565afa" + integrity sha512-C6bwM9RNINF2r7wTjQ7esjwlTnEIUZkJ+hyMGwOUxc4AqHCdc1PGEFwuMjQWhiyUtvR3hd7hfPj5GWvFP/PUTQ== + dependencies: + "@aws-sdk/abort-controller" "3.1.0" + "@aws-sdk/protocol-http" "3.2.0" + "@aws-sdk/querystring-builder" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/property-provider@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.1.0.tgz#19cf1da802a09be429e26f8eafec9b4c3473f8b0" + integrity sha512-NDCI7T8ytbzlXW8axDdtjMhJYRMVbOoPupM+HwR81hT7QoWWNrpCrjFqIv/gJet+ms5i4k80CWrBJd0L0kNl/Q== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/property-provider@3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.3.0.tgz#49979cb1a3e5562d51807c7403c5fd48cb9f2cdc" + integrity sha512-JTyjtXVNhFczL9IfgwXD55F6DqXL50PhfZxFW92t5dDj5VtWpOL74BbuxHQxHBgnQv1FKLr6N9cr7gfXWexDug== + dependencies: + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/protocol-http@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.1.0.tgz#7da0ebcf02a40a8300f3bd52f9206f25fdf1ca7f" + integrity sha512-61qInY/AESslV6ZYTAgwoB172K/H+5EiXeWnmWExOGH3vkfkkxQBYCTcATdtasP6QYTfYiePhyjJ8eUyQL3C0w== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/protocol-http@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.2.0.tgz#0d352045bae38c66a2c97611983bfb545351c4e0" + integrity sha512-7FA23ABTZ18MsQD0GcICj6dU9HRInf6l6XvtnlPUS6ZvjDbsNRqEROUtvjl8dkDDP1mFpEQuHJ5e9MO5peEm7g== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/querystring-builder@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.1.0.tgz#6cb859cfd906faa21de32641a960f080be78f0f3" + integrity sha512-rfVJWrAT+PJ5Dx0arxK5RBMfETezAjKGHxXWf7/9rcxSqoEz8s3tYhZ7/CqcOQqN89X6gJ4veQwGprraIhOZIg== + dependencies: + "@aws-sdk/util-uri-escape" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/querystring-parser@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.1.0.tgz#eec9fcbfc1c907e19909b4436f93a6c9fb063041" + integrity sha512-tqwHKLKplyv940+cR8uW6w2K3IEcGOskJ/8oP1R3RSyyidONSVEShNlpApTQlccfYriieDuEDLVCo4pT7S55kw== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/service-error-classification@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.1.0.tgz#9471f761f36ebe3efeb0791d7e2af517f87ef4a2" + integrity sha512-zUNV9Fyguto8VOhinKvzIoQxwfYMSSLO6xTKJLyTB+cDv51SX5sh+lqX6IKGhuqz2Wse1ynuoLGOnmVoe4aUpQ== + +"@aws-sdk/service-error-classification@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.2.0.tgz#286ba27368276132db89f21b79724bf95cbd8d97" + integrity sha512-ZzeSZ0HnJq5zUkA466zVEZAE1SVqYuvGzxRGTEszbMW2eWPLonJgUwiT5nDScmJQcDju044iP//2KnBvn62Rhg== + +"@aws-sdk/shared-ini-file-loader@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.1.0.tgz#b1bc7ed4e16535f20c788915060a121e457efdfb" + integrity sha512-5MxZ/CnSaWvecwtLWmcskMe41zBnAkckQRl+xKygl8wLD/q0goWcmMkA4Sx9fyFnGQtGN/+nNvu0dlG2Arxmvw== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/signature-v4@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.1.0.tgz#b5d480af74629e86163ffe86a3f13a37f1f1b250" + integrity sha512-TFbe/Xf02a4baU1tg/S/w1A5AKaD7YHuqQg4Ak+HbRvAFe77/C/rbqWGnl66BDcukMvx13Ywd5ZkraAVSaTlzg== + dependencies: + "@aws-sdk/is-array-buffer" "3.1.0" + "@aws-sdk/util-hex-encoding" "3.1.0" + "@aws-sdk/util-uri-escape" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/signature-v4@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.2.0.tgz#9eb5c4c3f5c3d448f3929f310baa4ad3682d1ddc" + integrity sha512-Fb81gqaSnuCwO9HFYnztksyFud5X6/Ikr95X3pfrtLcHlHe/S079woWmlDUEgy7lw2X2GY94TcX9jlXuMhTfhA== + dependencies: + "@aws-sdk/is-array-buffer" "3.1.0" + "@aws-sdk/util-hex-encoding" "3.1.0" + "@aws-sdk/util-uri-escape" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/smithy-client@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.1.0.tgz#dc60223b9af1e99d076871e8138ea9b6ecbf737b" + integrity sha512-l/xCyQ0xLvIX/VZDYC3CGAphXtV7/t1gqfxfATbx1TD3E23PS0XvaV8JAmMWF+Q+rShk+otuYpdaRjGrygTK5w== + dependencies: + "@aws-sdk/middleware-stack" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/smithy-client@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.2.0.tgz#ba93d125f83f385ce6a4600e47339bf036fc7d0a" + integrity sha512-LpBGQ5m/oQ6QkMPQl7yxvBll/NRww4GUCEwpEXw412SUbX7aVTu+CNoUmF2lg8Gwp2XM3NgOkJ9DQ94YwZ/dug== + dependencies: + "@aws-sdk/middleware-stack" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/types@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/types/-/types-3.1.0.tgz#04d77c37a80b422e8123f296338d129e51f3e1fc" + integrity sha512-4Az7cemXCN4Qp8EheNkZTJJqIG0dvCT2KAreJLoclcVTcEFw2rzlATUnSeia1YTRsVd6aNxD001Ug7f3vYcQkw== + +"@aws-sdk/types@^1.0.0-rc.1": + version "1.0.0-rc.10" + resolved "https://registry.npmjs.org/@aws-sdk/types/-/types-1.0.0-rc.10.tgz#729127fbfac5da1a3368ffe6ec2e90acc9ad69c3" + integrity sha512-9gwhYnkTNuYZ+etCtM4T8gjpZ0SWSXbzQxY34UjSS+dt3C/UnbX0J22tMahp/9Z1yCa9pihtXrkD+nO2xn7nVQ== + +"@aws-sdk/url-parser-browser@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/url-parser-browser/-/url-parser-browser-3.1.0.tgz#8e657d9e6cbde454df3b6e597e4fa4b690fe47af" + integrity sha512-XtiorfIxhbJuU1TLp7rL7qMzNY/+9FYisFtCFcrcy7/fsvXUTjjB2u3O4J01m/T6/HVmenV+SFQvs+fYC9U7dA== + dependencies: + "@aws-sdk/querystring-parser" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/url-parser-node@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/url-parser-node/-/url-parser-node-3.1.0.tgz#f49690405195f80157997fd48f99e1e036de3a3f" + integrity sha512-pDQekV4RK2hoiDB4YkMi00oTgU8MoHg+Sve9HaLYCtTuirHMVBswxzGg6MVDWo0tM6WSFbnQRXNZtioUo7ExVA== + dependencies: + "@aws-sdk/querystring-parser" "3.1.0" + tslib "^1.8.0" + url "^0.11.0" + +"@aws-sdk/util-arn-parser@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.1.0.tgz#56c462b2ef6d7cfa098e1a22e44fb3b52da0718c" + integrity sha512-xXL/nadq5mqEw6Mrv1ghoODuyWWsAxvr+rRNgDJOav6mypgEOiLb0ybkqinrH1ogTkAYbegs+uaWxgSPBe9ZSA== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/util-base64-browser@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-base64-browser/-/util-base64-browser-3.1.0.tgz#756253a3fc1ad58c38ad28ed664b701d850d3aa9" + integrity sha512-xkodj0VnkHl1gdYI9Nl4E2Ed+atM3xBTNaedoGnmqoyosMjPRJCpU8uFBmdiF4e+GGPsXlYe9oA/hLyJFxmeSQ== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/util-base64-node@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-base64-node/-/util-base64-node-3.1.0.tgz#027a9e854d204adda56d5f43f8ef4a20532b7ac9" + integrity sha512-FEtnINw2MeD3LAtyGcofah5D8j6OjpmwNKibr7mIgosRO++iVyXe2xa6iOoptZFn5pIU0C4fkJn5o+kjBhRafA== + dependencies: + "@aws-sdk/util-buffer-from" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/util-body-length-browser@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.1.0.tgz#b68018860cd5d63c9327c7a42323c5c75cf514bf" + integrity sha512-vzKDD/p1gcA05jeLmn6+6HdOY4G6Axyp6dj1R1nVeFpPPx6KkFsNGL9/CoaRT2TGv1fHBoDXsve9JRaCxrER4Q== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/util-body-length-node@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.1.0.tgz#c280223066f0ce8fadb002a668a5f5e6ee4de12b" + integrity sha512-MfJoU2wFWkOmbjWDepq5bDGYZlpvtBi2Vs8ZeTcm/4+q+3L9tJ/Zb/Ofx5oeRg9VhCsAjvceQTdX+CAyP8byXA== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/util-buffer-from@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.1.0.tgz#a68b2e191f3737bcd85c33af5a6b0e3e9f974f0e" + integrity sha512-UeC4VKmWYgTXjNdLVHfurrdhznnoxWLUFx8xspyRd58BhSZ5vc5HiiKTPX/CGxzAP/qZG668PaoOJucwmEam4g== + dependencies: + "@aws-sdk/is-array-buffer" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/util-hex-encoding@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.1.0.tgz#937440d60333c1b3e4fbe06012dfdc65c9e297bc" + integrity sha512-MPOsUY3USCUBaqZ3ifgE9il/liVxEKsz6dYQ08pdtWRzZx2CT7kWslQeNAT565pMvktnvdLjfzBw2FwnSI6nqg== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/util-locate-window@^1.0.0-rc.1": + version "1.0.0-rc.8" + resolved "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-1.0.0-rc.8.tgz#d28175aeb9c8ad3940242e615b1503632d3be33d" + integrity sha512-TvqeA4fgmZ0A0x3K+qVj/OSWEFHGZjzpVuyXlm1EYOf7NQ9VWRlokEn1MYKuL+t7al9ZeQyi16D8Dn7DW1eidw== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/util-uri-escape@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.1.0.tgz#1e4450c8e047b542f87172407e2fe0ade7c55227" + integrity sha512-1ZcXVJpsA6uW3tDTQI+Rpawqh76fyHpFc55ST8VGyMgmCzlJzBpYG0ck1kqVRSUP7YyvkJQvHfcm+U6doL5Xkw== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/util-user-agent-browser@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.1.0.tgz#471dec78305f39ac9a05968970917b5c66e4312b" + integrity sha512-w31WYjRTXbBn9z/sRy/4IyjIyEeTOon1JvRzlKDbEWm4JXarVbiJcZKs1U3q+9fS9la5uLM1NEQCsgdI1iYERA== + dependencies: + "@aws-sdk/types" "3.1.0" + bowser "^2.11.0" + tslib "^1.8.0" + +"@aws-sdk/util-user-agent-browser@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.2.0.tgz#f6fdee45817d80a637d7bd66985f516182b6f550" + integrity sha512-ugkLsntS2wpLWCjPs49wnAGhuIAFyHYzTznp0v+Qj/uOkm0ddq4kWp+FwtMxeQMRY0eoBdfqXJAbJLz8w4xlUQ== + dependencies: + bowser "^2.11.0" + tslib "^1.8.0" + +"@aws-sdk/util-user-agent-node@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.1.0.tgz#3fa66564a5fdb0e2851554688dc507127356f941" + integrity sha512-VyyZUR4vrHyT4aLH+ufoaTxFy65K9OtoCEdH1X748HinLZY9JObCCO6lZVNr5b26fGJrbqzoAqaXWgHkmEUpoQ== + dependencies: + "@aws-sdk/node-config-provider" "3.1.0" + "@aws-sdk/types" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/util-user-agent-node@3.2.0": + version "3.2.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.2.0.tgz#9190f69afdd81bacde16e253af2545a0e3f443cd" + integrity sha512-Ovyq6yc7SL0NWZWBrPKRc0D38xkMH2ulslcnukdNOitv0s8V2Ge09R4T63U0I8IJ7nrKa75Uus6A8vC8Zko3yg== + dependencies: + "@aws-sdk/node-config-provider" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/util-utf8-browser@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.1.0.tgz#7be17b545af101c320d34aace47139cf9987d796" + integrity sha512-vJP20me+Wc1RJHq+Y+gFD25aWhbQte+Qkyh3SOKQ+YvNaMcaeVwOV7b3Y3ItBuMdutHLJWmbJ2wF6dhhpy1kOA== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/util-utf8-browser@^1.0.0-rc.1": + version "1.0.0-rc.8" + resolved "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-1.0.0-rc.8.tgz#bf1f1cfed8c024f43a7c43b643fdf2b4523b5973" + integrity sha512-clncPMJ23rxCIkZ9LoUC8SowwZGxWyN2TwRb0XvW/Cv9EavkRgRCOrCpneGyC326lqtMKx36onnpaSRHxErUYw== + dependencies: + tslib "^1.8.0" + +"@aws-sdk/util-utf8-node@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-utf8-node/-/util-utf8-node-3.1.0.tgz#fd7e506b5fdf404fbbdd9bc46429afeea8114b25" + integrity sha512-lrBLkROMh9kTjHOguusqLvTX5+5O5CVpAGeISZlW6CCx2pMHtVRyE9cdNuRI8aJpyZsU12j8SoaKDUPGD+ixzw== + dependencies: + "@aws-sdk/util-buffer-from" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/util-waiter@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/util-waiter/-/util-waiter-3.1.0.tgz#e01dbd6bc903e03101152f65ab9d2e0613f08c2f" + integrity sha512-nMaE3aGCZGVQw10IUXBxIbCXkyaX8T1Fn7GvJxeFJflKcXuKFKBfvkhzimSe/hJDC6Ykn7gNuI+EY+ZoCVXqtQ== + dependencies: + "@aws-sdk/abort-controller" "3.1.0" + tslib "^1.8.0" + +"@aws-sdk/xml-builder@3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.1.0.tgz#ad7113075416436d8c822199674c45fc6ef42441" + integrity sha512-F6liCbWPMbnJq8d0qgzuXwG5O7jg1hhgiG71TTn83rnc6vFzyw2o0C+ztiqSZsbAq7r2PlEfBPWVD32gTFIXXw== + dependencies: + tslib "^1.8.0" + +"@azure/abort-controller@^1.0.0": + version "1.0.2" + resolved "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.0.2.tgz#822405c966b2aec16fb62c1b19d37eaccf231995" + integrity sha512-XUyTo+bcyxHEf+jlN2MXA7YU9nxVehaubngHV1MIZZaqYmZqykkoeAz/JMMEeR7t3TcyDwbFa3Zw8BZywmIx4g== + dependencies: + tslib "^2.0.0" + +"@azure/core-asynciterator-polyfill@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.0.tgz#dcccebb88406e5c76e0e1d52e8cc4c43a68b3ee7" + integrity sha512-kmv8CGrPfN9SwMwrkiBK9VTQYxdFQEGe0BmQk+M8io56P9KNzpAxcWE/1fxJj7uouwN4kXF0BHW8DNlgx+wtCg== + +"@azure/core-auth@^1.1.3": + version "1.1.4" + resolved "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.1.4.tgz#af9a334acf3cb9c49e6013e6caf6dc9d43476030" + integrity sha512-+j1embyH1jqf04AIfJPdLafd5SC1y6z1Jz4i+USR1XkTp6KM8P5u4/AjmWMVoEQdM/M29PJcRDZcCEWjK9S1bw== + dependencies: + "@azure/abort-controller" "^1.0.0" + tslib "^2.0.0" + +"@azure/core-http@^1.2.0": + version "1.2.2" + resolved "https://registry.npmjs.org/@azure/core-http/-/core-http-1.2.2.tgz#a6f7717184fd2657d3acabd1d64dfdc0bd531ce3" + integrity sha512-9eu2OcbR7e44gqBy4U1Uv8NTWgLIMwKXMEGgO2MahsJy5rdTiAhs5fJHQffPq8uX2MFh21iBODwO9R/Xlov88A== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.1.3" + "@azure/core-tracing" "1.0.0-preview.9" + "@azure/logger" "^1.0.0" + "@opentelemetry/api" "^0.10.2" + "@types/node-fetch" "^2.5.0" + "@types/tunnel" "^0.0.1" + form-data "^3.0.0" + node-fetch "^2.6.0" + process "^0.11.10" + tough-cookie "^4.0.0" + tslib "^2.0.0" + tunnel "^0.0.6" + uuid "^8.3.0" + xml2js "^0.4.19" + +"@azure/core-lro@^1.0.2": + version "1.0.3" + resolved "https://registry.npmjs.org/@azure/core-lro/-/core-lro-1.0.3.tgz#1ddfb4ecdb81ce87b5f5d972ffe2acbbc46e524e" + integrity sha512-Py2crJ84qx1rXkzIwfKw5Ni4WJuzVU7KAF6i1yP3ce8fbynUeu8eEWS4JGtSQgU7xv02G55iPDROifmSDbxeHA== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-http" "^1.2.0" + events "^3.0.0" + tslib "^2.0.0" + +"@azure/core-paging@^1.1.1": + version "1.1.3" + resolved "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.1.3.tgz#3587c9898a0530cacb64bab216d7318468aa5efc" + integrity sha512-his7Ah40ThEYORSpIAwuh6B8wkGwO/zG7gqVtmSE4WAJ46e36zUDXTKReUCLBDc6HmjjApQQxxcRFy5FruG79A== + dependencies: + "@azure/core-asynciterator-polyfill" "^1.0.0" + +"@azure/core-tracing@1.0.0-preview.9": + version "1.0.0-preview.9" + resolved "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.9.tgz#84f3b85572013f9d9b85e1e5d89787aa180787eb" + integrity sha512-zczolCLJ5QG42AEPQ+Qg9SRYNUyB+yZ5dzof4YEc+dyWczO9G2sBqbAjLB7IqrsdHN2apkiB2oXeDKCsq48jug== + dependencies: + "@opencensus/web-types" "0.0.7" + "@opentelemetry/api" "^0.10.2" + tslib "^2.0.0" + +"@azure/identity@^1.2.2": + version "1.2.2" + resolved "https://registry.npmjs.org/@azure/identity/-/identity-1.2.2.tgz#00d673c8881778c55777fcc76e822b42466d3fc7" + integrity sha512-aYkeNXl52aEHW1iOZQJb3SC7Vvbu87f01iNT+pSVHwj09LpN9+gP/Lb9uoWy36Fgv9WlukM55LbjLSbb1Renqw== + dependencies: + "@azure/core-http" "^1.2.0" + "@azure/core-tracing" "1.0.0-preview.9" + "@azure/logger" "^1.0.0" + "@azure/msal-node" "1.0.0-beta.3" + "@opentelemetry/api" "^0.10.2" + axios "^0.21.1" + events "^3.0.0" + jws "^4.0.0" + msal "^1.0.2" + open "^7.0.0" + qs "^6.7.0" + tslib "^2.0.0" + uuid "^8.3.0" + optionalDependencies: + keytar "^5.4.0" + +"@azure/logger@^1.0.0": + version "1.0.1" + resolved "https://registry.npmjs.org/@azure/logger/-/logger-1.0.1.tgz#19b333203d1b2931353d8879e814b64a7274837a" + integrity sha512-QYQeaJ+A5x6aMNu8BG5qdsVBnYBop9UMwgUvGihSjf1PdZZXB+c/oMdM2ajKwzobLBh9e9QuMQkN9iL+IxLBLA== + dependencies: + tslib "^2.0.0" + +"@azure/msal-common@^2.1.0": + version "2.1.0" + resolved "https://registry.npmjs.org/@azure/msal-common/-/msal-common-2.1.0.tgz#a4bc17e254d6ec524016f13267947dd4ff4a624d" + integrity sha512-Y1Id+jG59S3eY2ZQQtUA/lxwbRcgjcWaiib9YX+SwV3zeRauKfEiZT7l3z+lwV+T+Sst20F6l1mJsfQcfE7CEQ== dependencies: debug "^4.1.1" -"@azure/msal-node@^1.0.0-alpha.8": - version "1.0.0-alpha.12" - resolved "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.0.0-alpha.12.tgz#09d8d52f5cea90b133c3d48fe4ec477693040c91" - integrity sha512-uGLOJRWiEhfJIrTv/lwdm4RxQFm++00h83zNgDn0O3NkXlzAoCCq9QFYW84PjMR/Q2PUvVy7uW+6yKL/Nq3gBA== +"@azure/msal-node@1.0.0-beta.3", "@azure/msal-node@^1.0.0-beta.3": + version "1.0.0-beta.3" + resolved "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.0.0-beta.3.tgz#c84c7948028b39e48b901f5fac35bdedcbc8772e" + integrity sha512-/KfYRfrsOIrZONvo/0Vi5umuqbPBtCWNtmRvkse64uI0C4CP/W4WXwRD42VMws/8LtKvr1I5rYlYgFzt5zDz/A== dependencies: - "@azure/msal-common" "^1.6.2" - axios "^0.19.2" - debug "^4.1.1" + "@azure/msal-common" "^2.1.0" + axios "^0.21.1" jsonwebtoken "^8.5.1" uuid "^8.3.0" +"@azure/storage-blob@^12.4.0": + version "12.4.0" + resolved "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.4.0.tgz#7127ddd9f413105e2c3688691bc4c6245d0806b3" + integrity sha512-OnhVSoKD1HzBB79/rFzPbC4w9TdzFXeoOwkX+aIu3rb8qvN0VaqvUqZXSrBCyG2LcLyVkY4MPCJQBrmEUm9kvw== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-http" "^1.2.0" + "@azure/core-lro" "^1.0.2" + "@azure/core-paging" "^1.1.1" + "@azure/core-tracing" "1.0.0-preview.9" + "@azure/logger" "^1.0.0" + "@opentelemetry/api" "^0.10.2" + events "^3.0.0" + tslib "^2.0.0" + "@babel/code-frame@7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" @@ -1198,20 +2174,6 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-jsx" "^7.12.1" -"@babel/plugin-transform-react-jsx-self@^7.12.1": - version "7.12.1" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.12.1.tgz#ef43cbca2a14f1bd17807dbe4376ff89d714cf28" - integrity sha512-FbpL0ieNWiiBB5tCldX17EtXgmzeEZjFrix72rQYeq9X6nUK38HCaxexzVQrZWXanxKJPKVVIU37gFjEQYkPkA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-react-jsx-source@^7.12.1": - version "7.12.1" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.12.1.tgz#d07de6863f468da0809edcf79a1aa8ce2a82a26b" - integrity sha512-keQ5kBfjJNRc6zZN1/nVHCd6LLIHq4aUKcVnvE/2l+ZZROSbqoiGFRtT5t3Is89XJxBQaP7NLZX2jgGHdZvvFQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-transform-react-jsx@^7.0.0": version "7.10.4" resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.10.4.tgz#673c9f913948764a4421683b2bef2936968fddf2" @@ -1232,16 +2194,6 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-jsx" "^7.12.1" -"@babel/plugin-transform-react-jsx@^7.12.7": - version "7.12.7" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.7.tgz#8b14d45f6eccd41b7f924bcb65c021e9f0a06f7f" - integrity sha512-YFlTi6MEsclFAPIDNZYiCRbneg1MFGao9pPG9uD5htwE0vDbPaMUMeYd6itWjw7K4kro4UbdQf3ljmFl9y48dQ== - dependencies: - "@babel/helper-builder-react-jsx" "^7.10.4" - "@babel/helper-builder-react-jsx-experimental" "^7.12.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.12.1" - "@babel/plugin-transform-react-pure-annotations@^7.12.1": version "7.12.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.12.1.tgz#05d46f0ab4d1339ac59adf20a1462c91b37a1a42" @@ -1438,7 +2390,7 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/preset-react@^7.12.1": +"@babel/preset-react@^7.12.1", "@babel/preset-react@^7.12.5", "@babel/preset-react@^7.9.4": version "7.12.10" resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.12.10.tgz#4fed65f296cbb0f5fb09de6be8cddc85cc909be9" integrity sha512-vtQNjaHRl4DUpp+t+g4wvTHsLQuye+n0H/wsXIZRn69oz/fvNC7gQ4IK73zGJBaxvHoxElDvnYCthMcT7uzFoQ== @@ -1449,19 +2401,6 @@ "@babel/plugin-transform-react-jsx-development" "^7.12.7" "@babel/plugin-transform-react-pure-annotations" "^7.12.1" -"@babel/preset-react@^7.12.5", "@babel/preset-react@^7.9.4": - version "7.12.7" - resolved "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.12.7.tgz#36d61d83223b07b6ac4ec55cf016abb0f70be83b" - integrity sha512-wKeTdnGUP5AEYCYQIMeXMMwU7j+2opxrG0WzuZfxuuW9nhKvvALBjl67653CWamZJVefuJGI219G591RSldrqQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-transform-react-display-name" "^7.12.1" - "@babel/plugin-transform-react-jsx" "^7.12.7" - "@babel/plugin-transform-react-jsx-development" "^7.12.7" - "@babel/plugin-transform-react-jsx-self" "^7.12.1" - "@babel/plugin-transform-react-jsx-source" "^7.12.1" - "@babel/plugin-transform-react-pure-annotations" "^7.12.1" - "@babel/preset-typescript@^7.12.1": version "7.12.7" resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.12.7.tgz#fc7df8199d6aae747896f1e6c61fc872056632a3" @@ -1644,36 +2583,39 @@ to-fast-properties "^2.0.0" "@backstage/catalog-model@^0.2.0": - version "0.6.0" + version "0.7.1" dependencies: "@backstage/config" "^0.1.2" "@types/json-schema" "^7.0.5" "@types/yup" "^0.29.8" + ajv "^7.0.3" json-schema "^0.2.5" lodash "^4.17.15" uuid "^8.0.0" yup "^0.29.3" "@backstage/catalog-model@^0.3.0": - version "0.6.0" + version "0.7.1" dependencies: "@backstage/config" "^0.1.2" "@types/json-schema" "^7.0.5" "@types/yup" "^0.29.8" + ajv "^7.0.3" json-schema "^0.2.5" lodash "^4.17.15" uuid "^8.0.0" yup "^0.29.3" "@backstage/core@^0.3.0": - version "0.4.3" + version "0.6.0" dependencies: "@backstage/config" "^0.1.2" "@backstage/core-api" "^0.2.8" - "@backstage/theme" "^0.2.2" + "@backstage/theme" "^0.2.3" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.45" + "@testing-library/react-hooks" "^3.4.2" "@types/dagre" "^0.7.44" "@types/prop-types" "^15.7.3" "@types/react" "^16.9" @@ -1684,7 +2626,7 @@ d3-shape "^2.0.0" d3-zoom "^2.0.0" dagre "^0.8.5" - immer "^7.0.9" + immer "^8.0.1" lodash "^4.17.15" material-table "^1.69.1" prop-types "^15.7.2" @@ -1703,6 +2645,54 @@ remark-gfm "^1.0.0" zen-observable "^0.8.15" +"@backstage/plugin-catalog@^0.2.0": + version "0.3.0" + dependencies: + "@backstage/catalog-client" "^0.3.5" + "@backstage/catalog-model" "^0.7.1" + "@backstage/core" "^0.6.0" + "@backstage/plugin-catalog-react" "^0.0.2" + "@backstage/plugin-scaffolder" "^0.4.2" + "@backstage/theme" "^0.2.3" + "@material-ui/core" "^4.11.0" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + "@types/react" "^16.9" + classnames "^2.2.6" + git-url-parse "^11.4.4" + moment "^2.26.0" + react "^16.13.1" + react-dom "^16.13.1" + react-helmet "6.1.0" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-use "^15.3.3" + swr "^0.3.0" + +"@backstage/plugin-catalog@^0.2.1": + version "0.3.0" + dependencies: + "@backstage/catalog-client" "^0.3.5" + "@backstage/catalog-model" "^0.7.1" + "@backstage/core" "^0.6.0" + "@backstage/plugin-catalog-react" "^0.0.2" + "@backstage/plugin-scaffolder" "^0.4.2" + "@backstage/theme" "^0.2.3" + "@material-ui/core" "^4.11.0" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + "@types/react" "^16.9" + classnames "^2.2.6" + git-url-parse "^11.4.4" + moment "^2.26.0" + react "^16.13.1" + react-dom "^16.13.1" + react-helmet "6.1.0" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-use "^15.3.3" + swr "^0.3.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1713,53 +2703,54 @@ resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-5.0.0.tgz#3ba791f37b90e7f6170d252b63aacfcae943c039" integrity sha512-WmKrB/575EJCzbeSJR3YQ5sET5FaizeljLRw1382qVUeGqzuWBgIS+AF5a0FO51uQTrDpoRgvuHC2IWVsgwkkA== -"@changesets/apply-release-plan@^4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-4.0.0.tgz#e78efb56a4e459a8dab814ba43045f2ace0f27c9" - integrity sha512-MrcUd8wIlQ4S/PznzqJVsmnEpUGfPEkCGF54iqt8G05GEqi/zuxpoTfebcScpj5zeiDyxFIcA9RbeZ3pvJJxoA== +"@changesets/apply-release-plan@^4.2.0": + version "4.2.0" + resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-4.2.0.tgz#f1005815f27c3238f66507e90c6ae14d8fc62b41" + integrity sha512-/vt6UwgQldhOw93Gb8llI5OuYGlJt2+U45AfcXsoxzl8gZzCmChGm3vUaQJYbmtL8TbL8OOVXHRIKJJidMNPKw== dependencies: - "@babel/runtime" "^7.4.4" - "@changesets/config" "^1.2.0" + "@babel/runtime" "^7.10.4" + "@changesets/config" "^1.5.0" "@changesets/get-version-range-type" "^0.3.2" "@changesets/git" "^1.0.5" - "@changesets/types" "^3.1.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" + detect-indent "^6.0.0" fs-extra "^7.0.1" lodash.startcase "^4.4.0" outdent "^0.5.0" - prettier "^1.18.2" + prettier "^1.19.1" resolve-from "^5.0.0" semver "^5.4.1" -"@changesets/assemble-release-plan@^4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.0.0.tgz#60c2392c0e2c99f24778ab3a5c8e8c80ddaaaa59" - integrity sha512-3Kv21FNvysTQvZs3fHr6aZeDibhZHtgI1++fMZplzVtwNVmpjow3zv9lcZmJP26LthbpVH3I8+nqlU7M43lfWA== +"@changesets/assemble-release-plan@^4.0.0", "@changesets/assemble-release-plan@^4.1.0": + version "4.1.0" + resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.1.0.tgz#091e5e768dfe51835937e71d1ebaca1c9d6de55b" + integrity sha512-dMqe2L5Pn4UGTW89kOuuCuZD3pQFZj1Sxv92ZW4S98sXGsxcb2PdW+PeHbQ7tawkCYCOvzhXxAlN4OdF2DlDKQ== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" - "@changesets/types" "^3.1.0" + "@changesets/get-dependents-graph" "^1.2.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" semver "^5.4.1" -"@changesets/cli@^2.11.0": - version "2.11.0" - resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.11.0.tgz#048449289da95f24d96b187f3148c2955065d609" - integrity sha512-imsXWxl+QpLt6PYHSiuZS7xPdKrGs2c8saSG4ENzeZBgnTBMiJdxDloOT6Bmv15ICggHZj9mnaUBNOMPbvnlbA== +"@changesets/cli@^2.14.0": + version "2.14.0" + resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.14.0.tgz#b8d1d33d832c640ce0b95333bbd8d5ac5b9c9824" + integrity sha512-rbQMRDXl1cXOglnjUvYyrFLlYBbS0YZdfxZfW3ZbGLzLoS4n50+B9fLSE9oW20hQuL3zAnyLyacb9cwNhF2lig== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/apply-release-plan" "^4.0.0" - "@changesets/assemble-release-plan" "^4.0.0" - "@changesets/config" "^1.4.0" + "@changesets/apply-release-plan" "^4.2.0" + "@changesets/assemble-release-plan" "^4.1.0" + "@changesets/config" "^1.5.0" "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" + "@changesets/get-dependents-graph" "^1.2.0" "@changesets/get-release-plan" "^2.0.1" - "@changesets/git" "^1.0.6" + "@changesets/git" "^1.1.0" "@changesets/logger" "^0.0.5" "@changesets/pre" "^1.0.4" "@changesets/read" "^0.4.6" - "@changesets/types" "^3.2.0" + "@changesets/types" "^3.3.0" "@changesets/write" "^0.1.3" "@manypkg/get-packages" "^1.0.1" "@types/semver" "^6.0.0" @@ -1770,24 +2761,24 @@ fs-extra "^7.0.1" human-id "^1.0.2" is-ci "^2.0.0" - meow "^5.0.0" + meow "^6.0.0" outdent "^0.5.0" p-limit "^2.2.0" preferred-pm "^3.0.0" semver "^5.4.1" spawndamnit "^2.0.0" term-size "^2.1.0" - tty-table "^2.7.0" + tty-table "^2.8.10" -"@changesets/config@^1.2.0", "@changesets/config@^1.4.0": - version "1.4.0" - resolved "https://registry.npmjs.org/@changesets/config/-/config-1.4.0.tgz#c157a4121f198b749f2bbc2e9015b6e976ece7d6" - integrity sha512-eoTOcJ6py7jBDY8rUXwEGxR5UtvUX+p//0NhkVpPGcnvIeITHq+DOIsuWyGzWcb+1FaYkof3CCr32/komZTu4Q== +"@changesets/config@^1.2.0", "@changesets/config@^1.5.0": + version "1.5.0" + resolved "https://registry.npmjs.org/@changesets/config/-/config-1.5.0.tgz#6d58b01e45916318d3f39e9cde86a5d69dc58ed8" + integrity sha512-Bl9nLVYcwFCpd9jpzcOsExZk1NuTYX20D2YWHCdS1xll3W0yOdSUlWLUCCfugN1l3+yTR6iDW6q9o6vpCevWvA== dependencies: "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" + "@changesets/get-dependents-graph" "^1.2.0" "@changesets/logger" "^0.0.5" - "@changesets/types" "^3.2.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" fs-extra "^7.0.1" micromatch "^4.0.2" @@ -1799,12 +2790,12 @@ dependencies: extendable-error "^0.1.5" -"@changesets/get-dependents-graph@^1.1.3": - version "1.1.3" - resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.1.3.tgz#da959c43ce98f3a990a6b8d9c1f894bcc1b629c7" - integrity sha512-cTbySXwSv9yWp4Pp5R/b5Qv23wJgFaFCqUbsI3IJ2pyPl0vMaODAZS8NI1nNK2XSxGIg1tw+dWNSR4PlrKBSVQ== +"@changesets/get-dependents-graph@^1.2.0": + version "1.2.0" + resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.2.0.tgz#6986927a5fec60bc6fcc76f966efae2ac30c05ed" + integrity sha512-96NInEKpEZH8KvmXyh42PynXVAdq3kQ9VjAeswHtJ3umUCeTF42b/KVXaov+5P1RNnaUVtRuEwzs4syGuowDTw== dependencies: - "@changesets/types" "^3.0.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" chalk "^2.1.0" fs-extra "^7.0.1" @@ -1828,10 +2819,10 @@ resolved "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.3.2.tgz#8131a99035edd11aa7a44c341cbb05e668618c67" integrity sha512-SVqwYs5pULYjYT4op21F2pVbcrca4qA/bAA3FmFXKMN7Y+HcO8sbZUTx3TAy2VXulP2FACd1aC7f2nTuqSPbqg== -"@changesets/git@^1.0.5", "@changesets/git@^1.0.6": - version "1.0.6" - resolved "https://registry.npmjs.org/@changesets/git/-/git-1.0.6.tgz#057e627e5d3fcb74bf6c18d7284e130ba5a7632e" - integrity sha512-e0M06XuME3W5lGhz+CO0vLc60u+hLk/pYjOx/6GXEWuQrwtGgeycFIfRgRt8qTs664o1oKtVHBbd7ItpoWgFfA== +"@changesets/git@^1.0.5", "@changesets/git@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@changesets/git/-/git-1.1.0.tgz#ecaa8058ac87b450c09da0e74b68e6854cd0742c" + integrity sha512-f/2rQynT+JiAL/V0V/GQdXhLkcb86ELg3UwH3fQO4wVdfUbE9NHIHq9ohJdH1Ymh0Lv48F/b38aWZ5v2sKiF3w== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" @@ -1848,9 +2839,9 @@ chalk "^2.1.0" "@changesets/parse@^0.3.6": - version "0.3.6" - resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.6.tgz#8c2c8480fc07d2db2c37469d4a8df10906a989c6" - integrity sha512-0XPd/es9CfogI7XIqDr7I2mWzm++xX2s9GZsij3GajPYd7ouEsgJyNatPooxNtqj6ZepkiD6uqlqbeBUyj/A0Q== + version "0.3.7" + resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.7.tgz#1368136e2b83d5cff11b4d383a3032723530db99" + integrity sha512-8yqKulslq/7V2VRBsJqPgjnZMoehYqhJm5lEOXJPZ2rcuSdyj8+p/2vq2vRDBJT2m0rP+C9G8DujsGYQIFZezw== dependencies: "@changesets/types" "^3.0.0" js-yaml "^3.13.1" @@ -1880,15 +2871,10 @@ fs-extra "^7.0.1" p-filter "^2.1.0" -"@changesets/types@^3.0.0", "@changesets/types@^3.1.0", "@changesets/types@^3.1.1": - version "3.1.1" - resolved "https://registry.npmjs.org/@changesets/types/-/types-3.1.1.tgz#447481380c42044a8788e46c0dbdf592b338b62f" - integrity sha512-XWGEGWXhM92zvBWiQt2sOwhjTt8eCQbrsRbqkv4WYwW3Zsl4qPpvhHsNt845S42dJXrxgjWvId+jxFQocCayNQ== - -"@changesets/types@^3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@changesets/types/-/types-3.2.0.tgz#d8306d7219c3b19b6d860ddeb9d7374e2dd6b035" - integrity sha512-rAmPtOyXpisEEE25CchKNUAf2ApyAeuZ/h78YDoqKZaCk5tUD0lgYZGPIRV9WTPoqNjJULIym37ogc6pkax5jg== +"@changesets/types@^3.0.0", "@changesets/types@^3.1.0", "@changesets/types@^3.1.1", "@changesets/types@^3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@changesets/types/-/types-3.3.0.tgz#04cd8184b2d2da760667bd89bf9b930938dbd96e" + integrity sha512-rJamRo+OD/MQekImfIk07JZwYSB18iU6fYL8xOg0gfAiTh1a1+OlR1fPIxm55I7RsWw812is2YcPPwXdIewrhA== "@changesets/write@^0.1.3": version "0.1.3" @@ -2176,30 +3162,30 @@ dependencies: yaml-ast-parser "0.0.43" -"@gitbeaker/core@^25.2.0": - version "25.6.0" - resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-25.6.0.tgz#97d5ccc5d61bab6b678bec280036d594d275931e" - integrity sha512-+CohJNsbZiPl7jPgw7PHt5t0JIIV9NngObOskY1Ww8jef7SqaKpz0NsbSDawuWFBdmXApMpK81AEfASKtVI+cw== +"@gitbeaker/core@^28.0.2": + version "28.0.2" + resolved "https://registry.npmjs.org/@gitbeaker/core/-/core-28.0.2.tgz#5c2cbe27a20426a96d9511642153ead18ff88877" + integrity sha512-H+0ChYSxLh+EVNzmOguHaJMR7xB9FlVZGi89JQ7BJ2meq0h1TQl+QaGqTyTNM4GYhn7cYrl7ebGoU0yrnYUWSg== dependencies: - "@gitbeaker/requester-utils" "^25.6.0" + "@gitbeaker/requester-utils" "^28.0.2" form-data "^3.0.0" li "^1.3.0" xcase "^2.0.1" -"@gitbeaker/node@^25.2.0": - version "25.2.0" - resolved "https://registry.npmjs.org/@gitbeaker/node/-/node-25.2.0.tgz#cc91e83328ec32de0b1a0dac23accd2385734a66" - integrity sha512-FWchXYJ5agn0ptAQxtkkSKSg1ObbP2xfMzHLECxINFRBHYhg0ms8Fp8Qb+71pxJz7IMlvajyEtZaPfHBmyuh9Q== +"@gitbeaker/node@^28.0.2": + version "28.0.2" + resolved "https://registry.npmjs.org/@gitbeaker/node/-/node-28.0.2.tgz#ead4eb9e1b4400d3a6fe9769b50e43e84fc99747" + integrity sha512-FCILxXiRep3PUpe/P2+Ak1KdwzIz3azxf1OY8Jb72gKjNNTgCi4ByFCIpHkvbFZlr7kDKTtcPyEN6oA6qnrCdQ== dependencies: - "@gitbeaker/core" "^25.2.0" - "@gitbeaker/requester-utils" "^25.2.0" + "@gitbeaker/core" "^28.0.2" + "@gitbeaker/requester-utils" "^28.0.2" got "^11.7.0" xcase "^2.0.1" -"@gitbeaker/requester-utils@^25.2.0", "@gitbeaker/requester-utils@^25.6.0": - version "25.6.0" - resolved "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-25.6.0.tgz#001a432a48460bb5196a02ed71763eb707a1a01e" - integrity sha512-jD8cHbAZPR6+cB3HiukQxcqIKF5VkHtqg2m+Ns6ROE1pb0oRn10D/a9J1lZOXC9Jz2tQOBMWfHlplbmFFdB6Og== +"@gitbeaker/requester-utils@^28.0.2": + version "28.0.2" + resolved "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-28.0.2.tgz#3c1bf97e6b73053ef3c38a2871cbc8e1a2e7501a" + integrity sha512-S851vQi0x9Sg5Tx0BcwzkG6OTIG0X4qYTBaA9kPM2sEKg2OOsd8X0HRhj/TbFGr/YVz+3Ubu7oueeWSyr5jvfQ== dependencies: form-data "^3.0.0" query-string "^6.13.3" @@ -2753,6 +3739,15 @@ resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== +"@jest/console@^24.9.0": + version "24.9.0" + resolved "https://registry.npmjs.org/@jest/console/-/console-24.9.0.tgz#79b1bc06fb74a8cfb01cbdedf945584b1b9707f0" + integrity sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ== + dependencies: + "@jest/source-map" "^24.9.0" + chalk "^2.0.1" + slash "^2.0.0" + "@jest/console@^26.6.2": version "26.6.2" resolved "https://registry.npmjs.org/@jest/console/-/console-26.6.2.tgz#4e04bc464014358b03ab4937805ee36a0aeb98f2" @@ -2867,6 +3862,15 @@ optionalDependencies: node-notifier "^8.0.0" +"@jest/source-map@^24.9.0": + version "24.9.0" + resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-24.9.0.tgz#0e263a94430be4b41da683ccc1e6bffe2a191714" + integrity sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg== + dependencies: + callsites "^3.0.0" + graceful-fs "^4.1.15" + source-map "^0.6.0" + "@jest/source-map@^26.6.2": version "26.6.2" resolved "https://registry.npmjs.org/@jest/source-map/-/source-map-26.6.2.tgz#29af5e1e2e324cafccc936f218309f54ab69d535" @@ -2876,6 +3880,15 @@ graceful-fs "^4.2.4" source-map "^0.6.0" +"@jest/test-result@^24.9.0": + version "24.9.0" + resolved "https://registry.npmjs.org/@jest/test-result/-/test-result-24.9.0.tgz#11796e8aa9dbf88ea025757b3152595ad06ba0ca" + integrity sha512-XEFrHbBonBJ8dGp2JmF8kP/nQI/ImPpygKHwQ/SY+es59Z3L5PI4Qb9TQQMAEeYsThG1xF0k6tmG0tIKATNiiA== + dependencies: + "@jest/console" "^24.9.0" + "@jest/types" "^24.9.0" + "@types/istanbul-lib-coverage" "^2.0.0" + "@jest/test-result@^26.6.2": version "26.6.2" resolved "https://registry.npmjs.org/@jest/test-result/-/test-result-26.6.2.tgz#55da58b62df134576cc95476efa5f7949e3f5f18" @@ -2918,6 +3931,15 @@ source-map "^0.6.1" write-file-atomic "^3.0.0" +"@jest/types@^24.9.0": + version "24.9.0" + resolved "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz#63cb26cb7500d069e5a389441a7c6ab5e909fc59" + integrity sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^1.1.1" + "@types/yargs" "^13.0.0" + "@jest/types@^26.6.1": version "26.6.1" resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5" @@ -2945,10 +3967,10 @@ resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== -"@kubernetes/client-node@^0.12.1": - version "0.12.2" - resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.12.2.tgz#8728684bd57d1cbcbe14fe742e79be7021403eea" - integrity sha512-J0UwyFl1Iv/IZ6WMP7LaizBEoKPnqwtc8tIO2q/X+EuDT7eGpPPAMHXSEOC/EI9JGIf0FaJEcDHhB/Dio/mKhw== +"@kubernetes/client-node@^0.13.2": + version "0.13.2" + resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.13.2.tgz#881eb407afbd5b499e5daf898ffcc40f839709e7" + integrity sha512-ufvGfjBXuy5LbZTJZ8bd1eRVgWUd9rRDCryMWNfxb4372Q60R1oG8qy7svUB9NqBmwFgKHuaXkrfq3rFFbGrew== dependencies: "@types/js-yaml" "^3.12.1" "@types/node" "^10.12.0" @@ -2985,21 +4007,6 @@ dependencies: stream "^0.0.2" -"@kyma-project/asyncapi-react@^0.14.2": - version "0.14.2" - resolved "https://registry.npmjs.org/@kyma-project/asyncapi-react/-/asyncapi-react-0.14.2.tgz#6d99ef878f0481b05db0f3be7c94f4534176675f" - integrity sha512-lf3zcIuCTaVEIkCpF7Vwulrucgdfp1zXUIvkqjYPteOJjOuT6BAvnYwgvFUYibitmD/ADkvK8ybIeKdNJcyaPw== - dependencies: - "@asyncapi/avro-schema-parser" "^0.2.0" - "@asyncapi/openapi-schema-parser" "^2.0.0" - "@asyncapi/parser" "^1.0.1" - constate "^1.2.0" - dompurify "^2.1.1" - markdown-it "^11.0.1" - merge "^1.2.1" - openapi-sampler "^1.0.0-beta.15" - react-use "^12.2.0" - "@lerna/add@3.21.0": version "3.21.0" resolved "https://registry.npmjs.org/@lerna/add/-/add-3.21.0.tgz#27007bde71cc7b0a2969ab3c2f0ae41578b4577b" @@ -3874,24 +4881,38 @@ dependencies: mkdirp "^1.0.4" -"@octokit/auth-token@^2.4.0": - version "2.4.0" - resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.0.tgz#b64178975218b99e4dfe948253f0673cbbb59d9f" - integrity sha512-eoOVMjILna7FVQf96iWc3+ZtE/ZT6y8ob8ZzcqKY1ibSQCnu4O/B7pJvzMx5cyZ/RjAff6DAdEb0O0Cjcxidkg== +"@octokit/auth-app@^2.10.5": + version "2.10.5" + resolved "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-2.10.5.tgz#85d69cb96818f5da34bf0b81bb637d3675ad4e9a" + integrity sha512-6yXyjtcBWpuPYSdZN8z8IIjGSqkPmiJzdmCdod8at41ANB1FtaKbUIDL5+IkG+svv68NIYs+XORbhBRFXYB3bw== dependencies: - "@octokit/types" "^2.0.0" + "@octokit/request" "^5.4.11" + "@octokit/request-error" "^2.0.0" + "@octokit/types" "^6.0.3" + "@types/lru-cache" "^5.1.0" + deprecation "^2.3.1" + lru-cache "^6.0.0" + universal-github-app-jwt "^1.0.1" + universal-user-agent "^6.0.0" -"@octokit/core@^3.0.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@octokit/core/-/core-3.1.0.tgz#9c3c9b23f7504668cfa057f143ccbf0c645a0ac9" - integrity sha512-yPyQSmxIXLieEIRikk2w8AEtWkFdfG/LXcw1KvEtK3iP0ENZLW/WYQmdzOKqfSaLhooz4CJ9D+WY79C8ZliACw== +"@octokit/auth-token@^2.4.0", "@octokit/auth-token@^2.4.4": + version "2.4.4" + resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.4.tgz#ee31c69b01d0378c12fd3ffe406030f3d94d3b56" + integrity sha512-LNfGu3Ro9uFAYh10MUZVaT7X2CnNm2C8IDQmabx+3DygYIQjs9FwzFAHN/0t6mu5HEPhxcb1XOuxdpY82vCg2Q== dependencies: - "@octokit/auth-token" "^2.4.0" - "@octokit/graphql" "^4.3.1" - "@octokit/request" "^5.4.0" - "@octokit/types" "^5.0.0" + "@octokit/types" "^6.0.0" + +"@octokit/core@^3.2.3": + version "3.2.4" + resolved "https://registry.npmjs.org/@octokit/core/-/core-3.2.4.tgz#5791256057a962eca972e31818f02454897fd106" + integrity sha512-d9dTsqdePBqOn7aGkyRFe7pQpCXdibSJ5SFnrTr0axevObZrpz3qkWm7t/NjYv5a66z6vhfteriaq4FRz3e0Qg== + dependencies: + "@octokit/auth-token" "^2.4.4" + "@octokit/graphql" "^4.5.8" + "@octokit/request" "^5.4.12" + "@octokit/types" "^6.0.3" before-after-hook "^2.1.0" - universal-user-agent "^5.0.0" + universal-user-agent "^6.0.0" "@octokit/endpoint@^6.0.1": version "6.0.3" @@ -3902,28 +4923,19 @@ is-plain-object "^3.0.0" universal-user-agent "^5.0.0" -"@octokit/graphql@^4.3.1": - version "4.5.1" - resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.1.tgz#162aed1490320b88ce34775b3f6b8de945529fa9" - integrity sha512-qgMsROG9K2KxDs12CO3bySJaYoUu2aic90qpFrv7A8sEBzZ7UFGvdgPKiLw5gOPYEYbS0Xf8Tvf84tJutHPulQ== +"@octokit/graphql@^4.5.8": + version "4.5.8" + resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.8.tgz#d42373633c3015d0eafce64a8ce196be167fdd9b" + integrity sha512-WnCtNXWOrupfPJgXe+vSmprZJUr0VIu14G58PMlkWGj3cH+KLZEfKMmbUQ6C3Wwx6fdhzVW1CD5RTnBdUHxhhA== dependencies: "@octokit/request" "^5.3.0" - "@octokit/types" "^5.0.0" - universal-user-agent "^5.0.0" - -"@octokit/graphql@^4.5.6": - version "4.5.6" - resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.6.tgz#708143ba15cf7c1879ed6188266e7f270be805d4" - integrity sha512-Rry+unqKTa3svswT2ZAuqenpLrzJd+JTv89LTeVa5UM/5OX8o4KTkPL7/1ABq4f/ZkELb0XEK/2IEoYwykcLXg== - dependencies: - "@octokit/request" "^5.3.0" - "@octokit/types" "^5.0.0" + "@octokit/types" "^6.0.0" universal-user-agent "^6.0.0" -"@octokit/openapi-types@^2.0.0": - version "2.0.1" - resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.0.1.tgz#7453d8281ce66b8ed1607f7ac7d751c3baffd2cc" - integrity sha512-9AuC04PUnZrjoLiw3uPtwGh9FE4Q3rTqs51oNlQ0rkwgE8ftYsOC+lsrQyvCvWm85smBbSc0FNRKKumvGyb44Q== +"@octokit/openapi-types@^2.2.0": + version "2.2.0" + resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.2.0.tgz#123e0438a0bc718ccdac3b5a2e69b3dd00daa85b" + integrity sha512-274lNUDonw10kT8wHg8fCcUc1ZjZHbWv0/TbAwb0ojhBQqZYc1cQ/4yqTVTtPMDeZ//g7xVEYe/s3vURkRghPg== "@octokit/plugin-enterprise-rest@^6.0.1": version "6.0.1" @@ -3937,17 +4949,17 @@ dependencies: "@octokit/types" "^2.0.1" -"@octokit/plugin-paginate-rest@^2.2.0": - version "2.2.3" - resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.2.3.tgz#a6ad4377e7e7832fb4bdd9d421e600cb7640ac27" - integrity sha512-eKTs91wXnJH8Yicwa30jz6DF50kAh7vkcqCQ9D7/tvBAP5KKkg6I2nNof8Mp/65G0Arjsb4QcOJcIEQY+rK1Rg== +"@octokit/plugin-paginate-rest@^2.6.2": + version "2.7.0" + resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.7.0.tgz#6bb7b043c246e0654119a6ec4e72a172c9e2c7f3" + integrity sha512-+zARyncLjt9b0FjqPAbJo4ss7HOlBi1nprq+cPlw5vu2+qjy7WvlXhtXFdRHQbSL1Pt+bfAKaLADEkkvg8sP8w== dependencies: - "@octokit/types" "^5.0.0" + "@octokit/types" "^6.0.1" -"@octokit/plugin-request-log@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.0.tgz#eef87a431300f6148c39a7f75f8cfeb218b2547e" - integrity sha512-ywoxP68aOT3zHCLgWZgwUJatiENeHE7xJzYjfz8WI0goynp96wETBF+d95b8g/uL4QmS6owPVlaxiz3wyMAzcw== +"@octokit/plugin-request-log@^1.0.0", "@octokit/plugin-request-log@^1.0.2": + version "1.0.2" + resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.2.tgz#394d59ec734cd2f122431fbaf05099861ece3c44" + integrity sha512-oTJSNAmBqyDR41uSMunLQKMX0jmEXbwD1fpz8FG27lScV3RhtGfBa1/BBLym+PxcC16IBlF7KH9vP1BUYxA+Eg== "@octokit/plugin-rest-endpoint-methods@2.4.0": version "2.4.0" @@ -3957,20 +4969,12 @@ "@octokit/types" "^2.0.1" deprecation "^2.3.1" -"@octokit/plugin-rest-endpoint-methods@4.1.4": - version "4.1.4" - resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.1.4.tgz#ca60736d761b304fec02a2608caaec2d822e9835" - integrity sha512-Y2tVpSa7HjV3DGIQrQOJcReJ2JtcN9FaGr9jDa332Flro923/h3/Iu9e7Y4GilnzfLclHEh5iCQoCkHm7tWOcg== +"@octokit/plugin-rest-endpoint-methods@4.4.1": + version "4.4.1" + resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.4.1.tgz#105cf93255432155de078c9efc33bd4e14d1cd63" + integrity sha512-+v5PcvrUcDeFXf8hv1gnNvNLdm4C0+2EiuWt9EatjjUmfriM1pTMM+r4j1lLHxeBQ9bVDmbywb11e3KjuavieA== dependencies: - "@octokit/types" "^5.4.1" - deprecation "^2.3.1" - -"@octokit/plugin-rest-endpoint-methods@4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.2.0.tgz#c5a0691b3aba5d8b4ef5dffd6af3649608f167ba" - integrity sha512-1/qn1q1C1hGz6W/iEDm9DoyNoG/xdFDt78E3eZ5hHeUfJTLJgyAMdj9chL/cNBHjcjd+FH5aO1x0VCqR2RE0mw== - dependencies: - "@octokit/types" "^5.5.0" + "@octokit/types" "^6.1.0" deprecation "^2.3.1" "@octokit/request-error@^1.0.2": @@ -3991,19 +4995,19 @@ deprecation "^2.0.0" once "^1.4.0" -"@octokit/request@^5.2.0", "@octokit/request@^5.3.0", "@octokit/request@^5.4.0": - version "5.4.5" - resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.5.tgz#8df65bd812047521f7e9db6ff118c06ba84ac10b" - integrity sha512-atAs5GAGbZedvJXXdjtKljin+e2SltEs48B3naJjqWupYl2IUBbB/CJisyjbNHcKpHzb3E+OYEZ46G8eakXgQg== +"@octokit/request@^5.2.0", "@octokit/request@^5.3.0", "@octokit/request@^5.4.11", "@octokit/request@^5.4.12": + version "5.4.13" + resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.13.tgz#eec5987b3e96f984fc5f41967e001170c6d23a18" + integrity sha512-WcNRH5XPPtg7i1g9Da5U9dvZ6YbTffw9BN2rVezYiE7couoSyaRsw0e+Tl8uk1fArHE7Dn14U7YqUDy59WaqEw== dependencies: "@octokit/endpoint" "^6.0.1" "@octokit/request-error" "^2.0.0" - "@octokit/types" "^5.0.0" + "@octokit/types" "^6.0.3" deprecation "^2.0.0" - is-plain-object "^3.0.0" - node-fetch "^2.3.0" + is-plain-object "^5.0.0" + node-fetch "^2.6.1" once "^1.4.0" - universal-user-agent "^5.0.0" + universal-user-agent "^6.0.0" "@octokit/rest@^16.28.4": version "16.43.1" @@ -4027,25 +5031,15 @@ once "^1.4.0" universal-user-agent "^4.0.0" -"@octokit/rest@^18.0.0": - version "18.0.5" - resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.5.tgz#1f1498dcdc2d85d0f86b8168e4ff5779842b2742" - integrity sha512-SPKI24tQXrr1XsnaIjv2x0rl4M5eF1+hj8+vMe3d/exZ7NnL5sTe1BuFyCyJyrc+j1HkXankvgGN9zT0rwBwtg== +"@octokit/rest@^18.0.0", "@octokit/rest@^18.0.12": + version "18.0.12" + resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.12.tgz#278bd41358c56d87c201e787e8adc0cac132503a" + integrity sha512-hNRCZfKPpeaIjOVuNJzkEL6zacfZlBPV8vw8ReNeyUkVvbuCvvrrx8K8Gw2eyHHsmd4dPlAxIXIZ9oHhJfkJpw== dependencies: - "@octokit/core" "^3.0.0" - "@octokit/plugin-paginate-rest" "^2.2.0" - "@octokit/plugin-request-log" "^1.0.0" - "@octokit/plugin-rest-endpoint-methods" "4.1.4" - -"@octokit/rest@^18.0.6": - version "18.0.6" - resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.6.tgz#76c274f1a68f40741a131768ef483f041e7b98b6" - integrity sha512-ES4lZBKPJMX/yUoQjAZiyFjei9pJ4lTTfb9k7OtYoUzKPDLl/M8jiHqt6qeSauyU4eZGLw0sgP1WiQl9FYeM5w== - dependencies: - "@octokit/core" "^3.0.0" - "@octokit/plugin-paginate-rest" "^2.2.0" - "@octokit/plugin-request-log" "^1.0.0" - "@octokit/plugin-rest-endpoint-methods" "4.2.0" + "@octokit/core" "^3.2.3" + "@octokit/plugin-paginate-rest" "^2.6.2" + "@octokit/plugin-request-log" "^1.0.2" + "@octokit/plugin-rest-endpoint-methods" "4.4.1" "@octokit/types@^2.0.0", "@octokit/types@^2.0.1": version "2.5.0" @@ -4055,26 +5049,20 @@ "@types/node" ">= 8" "@octokit/types@^5.0.0", "@octokit/types@^5.0.1": - version "5.0.1" - resolved "https://registry.npmjs.org/@octokit/types/-/types-5.0.1.tgz#5459e9a5e9df8565dcc62c17a34491904d71971e" - integrity sha512-GorvORVwp244fGKEt3cgt/P+M0MGy4xEDbckw+K5ojEezxyMDgCaYPKVct+/eWQfZXOT7uq0xRpmrl/+hliabA== - dependencies: - "@types/node" ">= 8" - -"@octokit/types@^5.4.1": - version "5.4.1" - resolved "https://registry.npmjs.org/@octokit/types/-/types-5.4.1.tgz#d5d5f2b70ffc0e3f89467c3db749fa87fc3b7031" - integrity sha512-OlMlSySBJoJ6uozkr/i03nO5dlYQyE05vmQNZhAh9MyO4DPBP88QlwsDVLmVjIMFssvIZB6WO0ctIGMRG+xsJQ== - dependencies: - "@types/node" ">= 8" - -"@octokit/types@^5.5.0": version "5.5.0" resolved "https://registry.npmjs.org/@octokit/types/-/types-5.5.0.tgz#e5f06e8db21246ca102aa28444cdb13ae17a139b" integrity sha512-UZ1pErDue6bZNjYOotCNveTXArOMZQFG6hKJfOnGnulVCMcVVi7YIIuuR4WfBhjo7zgpmzn/BkPDnUXtNx+PcQ== dependencies: "@types/node" ">= 8" +"@octokit/types@^6.0.0", "@octokit/types@^6.0.1", "@octokit/types@^6.0.3", "@octokit/types@^6.1.0": + version "6.2.1" + resolved "https://registry.npmjs.org/@octokit/types/-/types-6.2.1.tgz#7f881fe44475ab1825776a4a59ca1ae082ed1043" + integrity sha512-jHs9OECOiZxuEzxMZcXmqrEO8GYraHF+UzNVH2ACYh8e/Y7YoT+hUf9ldvVd6zIvWv4p3NdxbQ0xx3ku5BnSiA== + dependencies: + "@octokit/openapi-types" "^2.2.0" + "@types/node" ">= 8" + "@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" @@ -4088,6 +5076,23 @@ fast-deep-equal "^3.1.3" lodash.clonedeep "^4.5.0" +"@opencensus/web-types@0.0.7": + version "0.0.7" + resolved "https://registry.npmjs.org/@opencensus/web-types/-/web-types-0.0.7.tgz#4426de1fe5aa8f624db395d2152b902874f0570a" + integrity sha512-xB+w7ZDAu3YBzqH44rCmG9/RlrOmFuDPt/bpf17eJr8eZSrLt7nc7LnWdxM9Mmoj/YKMHpxRg28txu3TcpiL+g== + +"@opentelemetry/api@^0.10.2": + version "0.10.2" + resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-0.10.2.tgz#9647b881f3e1654089ff7ea59d587b2d35060654" + integrity sha512-GtpMGd6vkzDMYcpu2t9LlhEgMy/SzBwRnz48EejlRArYqZzqSzAsKmegUK7zHgl+EOIaK9mKHhnRaQu3qw20cA== + dependencies: + "@opentelemetry/context-base" "^0.10.2" + +"@opentelemetry/context-base@^0.10.2": + version "0.10.2" + resolved "https://registry.npmjs.org/@opentelemetry/context-base/-/context-base-0.10.2.tgz#55bea904b2b91aa8a8675df9eaba5961bddb1def" + integrity sha512-hZNKjKOYsckoOEgBziGMnBcX0M7EtstnCmwz5jZUOUYwlZ+/xxX6z3jPu1XVO2Jivk0eLfuP9GP+vFD49CMetw== + "@panva/asn1.js@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6" @@ -4191,9 +5196,9 @@ shortid "^2.2.14" "@rjsf/material-ui@^2.4.0": - version "2.4.0" - resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.4.0.tgz#1b5859298bf3f61137d7b05084f058a775d6fd73" - integrity sha512-U8F/suzg4MuV+8mK1/ufs0Y6c3O8hc1wnuD2IKoOVJvegGfz5JCafyoyGAW6iyuT1DZBMPzVWEqfiuYPmoE7pw== + version "2.4.1" + resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.4.1.tgz#b0dedff8877b114147e298966ca3faba895a7a62" + integrity sha512-pZaWL5Dw+km8S0hFLIK1lRHaeNtheMxTF2mZrWhf6HlGWCTGkQJnXta2UgshJN/nKtZPgO1L4FKz42Eun9nnhg== "@roadiehq/backstage-plugin-buildkite@^0.1.3": version "0.1.3" @@ -4278,10 +5283,10 @@ react-router-dom "6.0.0-beta.0" react-use "^15.3.3" -"@rollup/plugin-commonjs@^16.0.0": - version "16.0.0" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-16.0.0.tgz#169004d56cd0f0a1d0f35915d31a036b0efe281f" - integrity sha512-LuNyypCP3msCGVQJ7ki8PqYdpjfEkE/xtFa5DqlF+7IBD0JsfMZ87C58heSwIMint58sAUZbt3ITqOmdQv/dXw== +"@rollup/plugin-commonjs@^17.1.0": + version "17.1.0" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.1.0.tgz#757ec88737dffa8aa913eb392fade2e45aef2a2d" + integrity sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew== dependencies: "@rollup/pluginutils" "^3.1.0" commondir "^1.0.1" @@ -4460,7 +5465,7 @@ react-syntax-highlighter "^13.5.0" regenerator-runtime "^0.13.7" -"@storybook/addons@6.1.11", "@storybook/addons@^6.1.11": +"@storybook/addons@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.11.tgz#cb4578411ca00ccb206b484df5a171ccaca34719" integrity sha512-OZXsdmn60dVe482l9zWxzOqqJApD2jggk/8QJKn3/Ub9posmqdqg712bW6v71BBe0UXXG/QfkZA7gcyiyEENbw== @@ -4475,6 +5480,21 @@ global "^4.3.2" regenerator-runtime "^0.13.7" +"@storybook/addons@6.1.15", "@storybook/addons@^6.1.11": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.15.tgz#09eb8d962f58bd20b4ac2f83b515831c83226352" + integrity sha512-ENyHapLFOG93VaoQXPX8O3IWjLRyVBox9C9P20LMruKX/SfXAXx20qsoAWKKPGssopyOin17aoQX9pj+lFmCZQ== + dependencies: + "@storybook/api" "6.1.15" + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/core-events" "6.1.15" + "@storybook/router" "6.1.15" + "@storybook/theming" "6.1.15" + core-js "^3.0.1" + global "^4.3.2" + regenerator-runtime "^0.13.7" + "@storybook/api@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.11.tgz#1e0b798203df823ac21184386258cf8b5f17f440" @@ -4500,6 +5520,31 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/api@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.15.tgz#285ba42f7a8efcd3bd0e586a5e978487d826fbb4" + integrity sha512-C4D08e2ZbSe62nNKtmh9YBraoWb2j6Chw8VCkuj91kuKHh3YDNc1gjj5Fi+KYZwIcy0EllzW3RFQs+YR1/Vg1g== + dependencies: + "@reach/router" "^1.3.3" + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/core-events" "6.1.15" + "@storybook/csf" "0.0.1" + "@storybook/router" "6.1.15" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.1.15" + "@types/reach__router" "^1.3.7" + core-js "^3.0.1" + fast-deep-equal "^3.1.1" + global "^4.3.2" + lodash "^4.17.15" + memoizerific "^1.11.3" + regenerator-runtime "^0.13.7" + store2 "^2.7.1" + telejson "^5.0.2" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/channel-postmessage@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.1.11.tgz#62c1079f04870dd27925bd538a2020e7380daa2e" @@ -4513,6 +5558,19 @@ qs "^6.6.0" telejson "^5.0.2" +"@storybook/channel-postmessage@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.1.15.tgz#80ea2346d18496f9710dd7f87fd2a9eca46ef36f" + integrity sha512-Es4B5zpLrW28KSbY8FhGVEDgUnKspJ7wPuJyKExUpZ5L9w52RkTD6lRnVPzLUfoQ4luPsExy5fiuo878/Wc9ag== + dependencies: + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/core-events" "6.1.15" + core-js "^3.0.1" + global "^4.3.2" + qs "^6.6.0" + telejson "^5.0.2" + "@storybook/channels@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.11.tgz#a93a83746ad78dd40e1c056029f6d93b17bb66bc" @@ -4522,6 +5580,15 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/channels@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.15.tgz#22bb06a671a5ae09d2537bcf63aaf90d7f6b9f6b" + integrity sha512-HIKHDeL/0BDk9a7xc2PLiFFoHjUMKUd2djhUGdeKgdKqoWejp4JJ60fI68+2QuSRbkB8k+rAwmuWJzV7EfB5fg== + dependencies: + core-js "^3.0.1" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/client-api@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.1.11.tgz#d25aac484ca84a1acb01d450e756a62408f00c1a" @@ -4546,6 +5613,30 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" +"@storybook/client-api@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.1.15.tgz#8f8ead111459b94621571bdb2276f8a0aace17b1" + integrity sha512-iwuDlgNdB6Y4OidlhWPob3tEIax9taymdKEe9by4rLJ3nfXu7viHcvCAjN24oI4NFW3NZsmtqJotgftRYk0r1Q== + dependencies: + "@storybook/addons" "6.1.15" + "@storybook/channel-postmessage" "6.1.15" + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/core-events" "6.1.15" + "@storybook/csf" "0.0.1" + "@types/qs" "^6.9.0" + "@types/webpack-env" "^1.15.3" + core-js "^3.0.1" + global "^4.3.2" + lodash "^4.17.15" + memoizerific "^1.11.3" + qs "^6.6.0" + regenerator-runtime "^0.13.7" + stable "^0.1.8" + store2 "^2.7.1" + ts-dedent "^2.0.0" + util-deprecate "^1.0.2" + "@storybook/client-logger@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.11.tgz#5dd092e4293e5f58f7e89ddbc6eb2511b7d60954" @@ -4554,6 +5645,14 @@ core-js "^3.0.1" global "^4.3.2" +"@storybook/client-logger@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.15.tgz#b558d6ecbee82c038d684717d8c598eaa4a9324d" + integrity sha512-lUpatG8SxzrUapWMsIPWiR+5qRVT5ebn8tGHQeBeRHXbdmEqyq5DOlrotLUemkA5nNTCs1pMFNvKSpCHznG+fg== + dependencies: + core-js "^3.0.1" + global "^4.3.2" + "@storybook/components@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.11.tgz#edd5db7fe43f47b5a7ab515840795a89d931512e" @@ -4580,6 +5679,32 @@ react-textarea-autosize "^8.1.1" ts-dedent "^2.0.0" +"@storybook/components@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.15.tgz#b4a2af23ee6b9cba4c255191eae3d3463e29bfb7" + integrity sha512-lPbA/zyBfctdlpDhRTcRFLWlZPJ3PB4+wI0FUvYs69iG3/bNbQPYu8vRmNhCZOsaGt+b+dik4Tfcth8Bu+eQug== + dependencies: + "@popperjs/core" "^2.5.4" + "@storybook/client-logger" "6.1.15" + "@storybook/csf" "0.0.1" + "@storybook/theming" "6.1.15" + "@types/overlayscrollbars" "^1.9.0" + "@types/react-color" "^3.0.1" + "@types/react-syntax-highlighter" "11.0.4" + core-js "^3.0.1" + fast-deep-equal "^3.1.1" + global "^4.3.2" + lodash "^4.17.15" + markdown-to-jsx "^6.11.4" + memoizerific "^1.11.3" + overlayscrollbars "^1.10.2" + polished "^3.4.4" + react-color "^2.17.0" + react-popper-tooltip "^3.1.1" + react-syntax-highlighter "^13.5.0" + react-textarea-autosize "^8.1.1" + ts-dedent "^2.0.0" + "@storybook/core-events@6.1.11": version "6.1.11" resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.11.tgz#d50e8ec90490f9a7180a8c8a83afb6dcfe47ed66" @@ -4587,10 +5712,17 @@ dependencies: core-js "^3.0.1" -"@storybook/core@6.1.11": - version "6.1.11" - resolved "https://registry.npmjs.org/@storybook/core/-/core-6.1.11.tgz#ed9d3b513794c604ab11180f6a014924b871179e" - integrity sha512-pYOOQwiNJ5myLRn6p6nnLUjjjISHK/N55vS4HFnETYSaRLA++h1coN1jk7Zwt89dOQTdF0EsTJn+6snYOC+lxQ== +"@storybook/core-events@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.15.tgz#f66e30cbed8afdb8df2254d2aa47fe139e641c60" + integrity sha512-2sz02hdGZshanoq83jaB+goAcapVEWrxe+RJZn/gu2OymlEioWNjPPtOVGgi5DNIiJFnYvc66adayNwX39+tDA== + dependencies: + core-js "^3.0.1" + +"@storybook/core@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/core/-/core-6.1.15.tgz#7ff8c314d3857497bf2e26c69a1fa93ef37301aa" + integrity sha512-mQeKAXcowUwF+pOdWZEFwb5M6sz4yv5cOv1vTci3/1pMmB8QpYlH+P61p4lsRO17Vlak70h18TworPka/4+mhA== dependencies: "@babel/core" "^7.12.3" "@babel/plugin-proposal-class-properties" "^7.12.1" @@ -4614,20 +5746,20 @@ "@babel/preset-react" "^7.12.1" "@babel/preset-typescript" "^7.12.1" "@babel/register" "^7.12.1" - "@storybook/addons" "6.1.11" - "@storybook/api" "6.1.11" - "@storybook/channel-postmessage" "6.1.11" - "@storybook/channels" "6.1.11" - "@storybook/client-api" "6.1.11" - "@storybook/client-logger" "6.1.11" - "@storybook/components" "6.1.11" - "@storybook/core-events" "6.1.11" + "@storybook/addons" "6.1.15" + "@storybook/api" "6.1.15" + "@storybook/channel-postmessage" "6.1.15" + "@storybook/channels" "6.1.15" + "@storybook/client-api" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/components" "6.1.15" + "@storybook/core-events" "6.1.15" "@storybook/csf" "0.0.1" - "@storybook/node-logger" "6.1.11" - "@storybook/router" "6.1.11" + "@storybook/node-logger" "6.1.15" + "@storybook/router" "6.1.15" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.1.11" - "@storybook/ui" "6.1.11" + "@storybook/theming" "6.1.15" + "@storybook/ui" "6.1.15" "@types/glob-base" "^0.3.0" "@types/micromatch" "^4.0.1" "@types/node-fetch" "^2.5.4" @@ -4701,10 +5833,10 @@ dependencies: lodash "^4.17.15" -"@storybook/node-logger@6.1.11": - version "6.1.11" - resolved "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.1.11.tgz#8e0d058b4804f2fea03c9d7d331b8e2d02f3b7ff" - integrity sha512-MASonXDWpSMU9HF9mqbGOR1Ps/DTJ8AVmYD50+OnB9kXl4M42Dliobeq7JwKFMnZ42RelUCCSXdWW80hGrUKKA== +"@storybook/node-logger@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.1.15.tgz#fcf786d3a323feb6821e40e26f98a513a60d1a79" + integrity sha512-lrO0ei3W7BRci2iUkWTr/rXgHkzxwZTrlkx0iBzbQQRy7K1AJ9bjzhurCH9B8C9XGLmn60LXT81RWD3iCLZjcw== dependencies: "@types/npmlog" "^4.1.2" chalk "^4.0.0" @@ -4713,16 +5845,16 @@ pretty-hrtime "^1.0.3" "@storybook/react@^6.1.11": - version "6.1.11" - resolved "https://registry.npmjs.org/@storybook/react/-/react-6.1.11.tgz#e94403cd878c66b445df993bad9bec9023db3ebe" - integrity sha512-EmR7yvVW6z6AYhfzAgJMGR/5+igeBGa1EePaEIibn51r5uboSB72N12NaADyF2OaycIdV+0sW6vP9Zvlvexa/w== + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/react/-/react-6.1.15.tgz#e17d00b05b8980ad381ba701805309ed46d1fdcd" + integrity sha512-7WoYLOZuAlzgQsL9oy4JCr9NcB4NBCuxslPSncN5l/7ewGXgfVXTAOMOfw+EVNrtUeVJU2fC8gFiHVl0SJpTZw== dependencies: "@babel/preset-flow" "^7.12.1" "@babel/preset-react" "^7.12.1" "@pmmmwh/react-refresh-webpack-plugin" "^0.4.2" - "@storybook/addons" "6.1.11" - "@storybook/core" "6.1.11" - "@storybook/node-logger" "6.1.11" + "@storybook/addons" "6.1.15" + "@storybook/core" "6.1.15" + "@storybook/node-logger" "6.1.15" "@storybook/semver" "^7.3.2" "@types/webpack-env" "^1.15.3" babel-plugin-add-react-displayname "^0.0.5" @@ -4751,6 +5883,18 @@ memoizerific "^1.11.3" qs "^6.6.0" +"@storybook/router@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.1.15.tgz#e0cd7440a2ddc9b265e506b1cb590d3eeab56476" + integrity sha512-HlxDkGpiTSxXCJuqRoZ9Viq6Y/h/7efI8LPhhopr50qWRBTh/PEQzDqWBXG3sj8ISmi9GyUaTSAuqRwdA3lJQQ== + dependencies: + "@reach/router" "^1.3.3" + "@types/reach__router" "^1.3.7" + core-js "^3.0.1" + global "^4.3.2" + memoizerific "^1.11.3" + qs "^6.6.0" + "@storybook/semver@^7.3.2": version "7.3.2" resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0" @@ -4794,21 +5938,39 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" -"@storybook/ui@6.1.11": - version "6.1.11" - resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.1.11.tgz#2e5a5df010f2bb75a09a0fd0439fc8e62f8c89e5" - integrity sha512-Qth2dxS5+VbKHcqgkiKpeD+xr/hRUuUIDUA/2Ierh/BaA8Up/krlso/mCLaQOa5E8Og9WJAdDFO0cUbt939c2Q== +"@storybook/theming@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.1.15.tgz#01083ab89904dd959429b0b3fd1c76bd0ecc59ef" + integrity sha512-88IdYaPzp4NMKf/GKBrPggxD6/d/lkdQ4SNowXxN9g9eONd9M7HtTbjuJGRCbGMJ52xGcbpj2exEnAqKQ2iodA== dependencies: "@emotion/core" "^10.1.1" - "@storybook/addons" "6.1.11" - "@storybook/api" "6.1.11" - "@storybook/channels" "6.1.11" - "@storybook/client-logger" "6.1.11" - "@storybook/components" "6.1.11" - "@storybook/core-events" "6.1.11" - "@storybook/router" "6.1.11" + "@emotion/is-prop-valid" "^0.8.6" + "@emotion/styled" "^10.0.23" + "@storybook/client-logger" "6.1.15" + core-js "^3.0.1" + deep-object-diff "^1.1.0" + emotion-theming "^10.0.19" + global "^4.3.2" + memoizerific "^1.11.3" + polished "^3.4.4" + resolve-from "^5.0.0" + ts-dedent "^2.0.0" + +"@storybook/ui@6.1.15": + version "6.1.15" + resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.1.15.tgz#a0f6c49fcf81cf172cd2de4c8dba2be1296891f6" + integrity sha512-quyhJWlOxhk95he7s5/TSYM3eEsaz3s4+98kUZE6r3ssME8u6zDvqa/qa6EWs5/nvZ2V3+12efIzCNbiiT3v3g== + dependencies: + "@emotion/core" "^10.1.1" + "@storybook/addons" "6.1.15" + "@storybook/api" "6.1.15" + "@storybook/channels" "6.1.15" + "@storybook/client-logger" "6.1.15" + "@storybook/components" "6.1.15" + "@storybook/core-events" "6.1.15" + "@storybook/router" "6.1.15" "@storybook/semver" "^7.3.2" - "@storybook/theming" "6.1.11" + "@storybook/theming" "6.1.15" "@types/markdown-to-jsx" "^6.11.0" copy-to-clipboard "^3.0.8" core-js "^3.0.1" @@ -5260,6 +6422,11 @@ resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A== +"@types/aws4@^1.5.1": + version "1.5.1" + resolved "https://registry.npmjs.org/@types/aws4/-/aws4-1.5.1.tgz#361fadab198a030ab398269183ae3fa86e958ed9" + integrity sha1-Nh+tqxmKAwqzmCaRg64/qG6Vjtk= + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.9" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d" @@ -5517,11 +6684,6 @@ dependencies: "@types/node" "*" -"@types/eslint-visitor-keys@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" - integrity sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag== - "@types/eslint@*": version "6.1.8" resolved "https://registry.npmjs.org/@types/eslint/-/eslint-6.1.8.tgz#7e868f89bc1e520323d405940e49cb912ede5bba" @@ -5541,9 +6703,9 @@ integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": - version "4.17.9" - resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.9.tgz#2d7b34dcfd25ec663c25c85d76608f8b249667f1" - integrity sha512-DG0BYg6yO+ePW+XoDENYz8zhNGC3jDDEpComMYn7WJc4mY1Us8Rw9ax2YhJXxpyk2SF47PQAoQ0YyVT1a0bEkA== + version "4.17.18" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz#8371e260f40e0e1ca0c116a9afcd9426fa094c40" + integrity sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA== dependencies: "@types/node" "*" "@types/qs" "*" @@ -5646,13 +6808,6 @@ dependencies: "@types/unist" "*" -"@types/helmet@^0.0.48": - version "0.0.48" - resolved "https://registry.npmjs.org/@types/helmet/-/helmet-0.0.48.tgz#e754399d2f4672ba63962e8490efd3edd31d9799" - integrity sha512-C7MpnvSDrunS1q2Oy1VWCY7CDWHozqSnM8P4tFeRTuzwqni+PYOjEredwcqWG+kLpYcgLsgcY3orHB54gbx2Jw== - dependencies: - "@types/express" "*" - "@types/history@*": version "4.7.5" resolved "https://registry.npmjs.org/@types/history/-/history-4.7.5.tgz#527d20ef68571a4af02ed74350164e7a67544860" @@ -5744,6 +6899,14 @@ dependencies: "@types/istanbul-lib-coverage" "*" +"@types/istanbul-reports@^1.1.1": + version "1.1.2" + resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz#e875cc689e47bce549ec81f3df5e6f6f11cfaeb2" + integrity sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw== + dependencies: + "@types/istanbul-lib-coverage" "*" + "@types/istanbul-lib-report" "*" + "@types/istanbul-reports@^3.0.0": version "3.0.0" resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz#508b13aa344fa4976234e75dddcc34925737d821" @@ -5751,6 +6914,13 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest-when@^2.7.2": + version "2.7.2" + resolved "https://registry.npmjs.org/@types/jest-when/-/jest-when-2.7.2.tgz#619fbc5f623bcd0b29efde0e4993c7f0d50d026d" + integrity sha512-vOtj0cev6vO1VX7Jbfg/qvy+sfLI64STsHbKVkggK+1kd11rcMGzFpZKBxUvQfsm4JRULCBISu+qrfs7fYZFGg== + dependencies: + "@types/jest" "*" + "@types/jest@*", "@types/jest@26.x", "@types/jest@^26.0.7": version "26.0.15" resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe" @@ -5760,9 +6930,9 @@ pretty-format "^26.0.0" "@types/jquery@^3.3.34": - version "3.5.1" - resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.1.tgz#cebb057acf5071c40e439f30e840c57a30d406c3" - integrity sha512-Tyctjh56U7eX2b9udu3wG853ASYP0uagChJcQJXLUXEU6C/JiW5qt5dl8ao01VRj1i5pgXPAf8f1mq4+FDLRQg== + version "3.5.5" + resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.5.tgz#2c63f47c9c8d96693d272f5453602afd8338c903" + integrity sha512-6RXU9Xzpc6vxNrS6FPPapN1SxSHgQ336WC6Jj/N8q30OiaBZ00l1GBgeP7usjVZPivSkGUfL1z/WW6TX989M+w== dependencies: "@types/sizzle" "*" @@ -5798,7 +6968,7 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= -"@types/jsonwebtoken@^8.5.0": +"@types/jsonwebtoken@^8.3.3", "@types/jsonwebtoken@^8.5.0": version "8.5.0" resolved "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz#2531d5e300803aa63279b232c014acf780c981c5" integrity sha512-9bVao7LvyorRGZCw0VmH/dr7Og+NdjYSsKAxB43OQoComFbBgsEpoR9JW6+qSq/ogwVBg8GI2MfAlk4SYI4OLg== @@ -5861,6 +7031,16 @@ resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== +"@types/lru-cache@^5.1.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" + integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== + +"@types/luxon@^1.25.0": + version "1.25.0" + resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.25.0.tgz#3d6fe591fac874f48dd225cb5660b2b785a21a05" + integrity sha512-iIJp2CP6C32gVqI08HIYnzqj55tlLnodIBMCcMf28q9ckqMfMzocCmIzd9JWI/ALLPMUiTkCu1JGv3FFtu6t3g== + "@types/markdown-to-jsx@^6.11.0": version "6.11.2" resolved "https://registry.npmjs.org/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.2.tgz#05d1aaffbf15be7be12c70535fa4fed65cc7c64f" @@ -5892,10 +7072,10 @@ resolved "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== -"@types/mini-css-extract-plugin@^0.9.1": - version "0.9.1" - resolved "https://registry.npmjs.org/@types/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.1.tgz#d4bdde5197326fca039d418f4bdda03dc74dc451" - integrity sha512-+mN04Oszdz9tGjUP/c1ReVwJXxSniLd7lF++sv+8dkABxVNthg6uccei+4ssKxRHGoMmPxdn7uBdJWONSJGTGQ== +"@types/mini-css-extract-plugin@^1.2.2": + version "1.2.2" + resolved "https://registry.npmjs.org/@types/mini-css-extract-plugin/-/mini-css-extract-plugin-1.2.2.tgz#e6031da8d60777b3da3f5b4daf285437d7b6580b" + integrity sha512-EoHBJ4rcrd5j7weAFE4yU1gxedx53EFCWKso03G7DW0h2YvtwjKYz/NnuFHudcQDI1HpTLqoQFTwEgfJxygYCw== dependencies: "@types/webpack" "*" @@ -5937,13 +7117,6 @@ dependencies: "@types/node" "*" -"@types/nock@^11.1.0": - version "11.1.0" - resolved "https://registry.npmjs.org/@types/nock/-/nock-11.1.0.tgz#0a8c1056a31ba32a959843abccf99626dd90a538" - integrity sha512-jI/ewavBQ7X5178262JQR0ewicPAcJhXS/iFaNJl0VHLfyosZ/kwSrsa6VNQNSO8i9d8SqdRgOtZSOKJ/+iNMw== - dependencies: - nock "*" - "@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.4": version "2.5.7" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" @@ -5952,6 +7125,14 @@ "@types/node" "*" form-data "^3.0.0" +"@types/node-fetch@^2.5.0": + version "2.5.8" + resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz#e199c835d234c7eb0846f6618012e558544ee2fb" + integrity sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node@*", "@types/node@>= 8": version "14.0.26" resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c" @@ -5989,20 +7170,6 @@ dependencies: "@types/node" "*" -"@types/openid-client@^3.7.0": - version "3.7.0" - resolved "https://registry.npmjs.org/@types/openid-client/-/openid-client-3.7.0.tgz#8406e12798d16083df09cc3625973f5a00dd57fa" - integrity sha512-hW+QbAeUyfUHABwUjUECOcv56Mf5tN29xK3GPN7wy3R3XfIQdkm37XELA4wbe2RNG9GYUpN8l2ytfoW05XmpgQ== - dependencies: - openid-client "*" - -"@types/ora@^3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@types/ora/-/ora-3.2.0.tgz#b2f65d1283a8f36d8b0f9ee767e0732a2f429362" - integrity sha512-jll99xUKpiFbIFZSQcxm4numfsLaOWBzWNaRk3PvTSE7BPqTzzOCFmS0mQ7m8qkTfmYhuYbehTGsxkvRLPC++w== - dependencies: - ora "*" - "@types/overlayscrollbars@^1.9.0": version "1.12.0" resolved "https://registry.npmjs.org/@types/overlayscrollbars/-/overlayscrollbars-1.12.0.tgz#98456caceca8ad73bd5bb572632a585074e70764" @@ -6055,10 +7222,18 @@ "@types/express" "*" "@types/passport" "*" +"@types/passport-strategy@^0.2.35": + version "0.2.35" + resolved "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz#e52f5212279ea73f02d9b06af67efe9cefce2d0c" + integrity sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport@*", "@types/passport@^1.0.3": - version "1.0.4" - resolved "https://registry.npmjs.org/@types/passport/-/passport-1.0.4.tgz#1b35c4e197560d3974fa5f71711b6e9cce0711f0" - integrity sha512-h5OfAbfBBYSzjeU0GTuuqYEk9McTgWeGQql9g3gUw2/NNCfD7VgExVRYJVVeU13Twn202Mvk9BT0bUrl30sEgA== + version "1.0.5" + resolved "https://registry.npmjs.org/@types/passport/-/passport-1.0.5.tgz#1ff54ec3e30fa6480c5e8b8de949c6dc40ddfa2a" + integrity sha512-wNL4kT/5rnZgyGkqX7V2qH/R/te+bklv+nXcvHzyX99vNggx9DGN+F8CEOW3P/gRi7Cjm991uidRgTHsYkSuMg== dependencies: "@types/express" "*" @@ -6105,6 +7280,13 @@ "@types/history" "*" "@types/react" "*" +"@types/reach__router@^1.3.7": + version "1.3.7" + resolved "https://registry.npmjs.org/@types/reach__router/-/reach__router-1.3.7.tgz#de8ab374259ae7f7499fc1373b9697a5f3cd6428" + integrity sha512-cyBEb8Ef3SJNH5NYEIDGPoMMmYUxROatuxbICusVRQIqZUB85UCt6R2Ok60tKS/TABJsJYaHyNTW3kqbpxlMjg== + dependencies: + "@types/react" "*" + "@types/react-color@^3.0.1": version "3.0.4" resolved "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.4.tgz#c63daf012ad067ac0127bdd86725f079d02082bd" @@ -6297,6 +7479,11 @@ resolved "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== +"@types/stack-utils@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" + integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== + "@types/stack-utils@^2.0.0": version "2.0.0" resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" @@ -6405,6 +7592,13 @@ resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" integrity sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A== +"@types/tunnel@^0.0.1": + version "0.0.1" + resolved "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.1.tgz#0d72774768b73df26f25df9184273a42da72b19c" + integrity sha512-AOqu6bQu5MSWwYvehMXLukFHnupHrpZ8nvgae5Ggie9UwzDR1CCwoXgSSWNZJuyOlCdfdsWMA5F2LlmvyoTv8A== + dependencies: + "@types/node" "*" + "@types/uglify-js@*": version "3.0.4" resolved "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082" @@ -6445,12 +7639,7 @@ "@types/serve-static" "*" "@types/webpack" "*" -"@types/webpack-env@^1.15.2": - version "1.15.3" - resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.15.3.tgz#fb602cd4c2f0b7c0fb857e922075fdf677d25d84" - integrity sha512-5oiXqR7kwDGZ6+gmzIO2lTC+QsriNuQXZDWNYRV3l2XRN/zmPgnC21DLSx2D05zvD8vnXW6qUg7JnXZ4I6qLVQ== - -"@types/webpack-env@^1.15.3": +"@types/webpack-env@^1.15.2", "@types/webpack-env@^1.15.3": version "1.16.0" resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.0.tgz#8c0a9435dfa7b3b1be76562f3070efb3f92637b4" integrity sha512-Fx+NpfOO0CpeYX2g9bkvX8O5qh9wrU1sOF4g8sft4Mu7z+qfe387YlyY8w8daDyDsKY5vUxM0yxkAYnbkRbZEw== @@ -6472,9 +7661,9 @@ source-map "^0.6.1" "@types/webpack@*", "@types/webpack@^4.41.7", "@types/webpack@^4.41.8": - version "4.41.22" - resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.22.tgz#ff9758a17c6bd499e459b91e78539848c32d0731" - integrity sha512-JQDJK6pj8OMV9gWOnN1dcLCyU9Hzs6lux0wBO4lr1+gyEhIBR9U3FMrz12t2GPkg110XAxEAw2WHF6g7nZIbRQ== + version "4.41.26" + resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.26.tgz#27a30d7d531e16489f9c7607c747be6bc1a459ef" + integrity sha512-7ZyTfxjCRwexh+EJFwRUM+CDB2XvgHl4vfuqf1ZKrgGvcS5BrNvPQqJh3tsZ0P6h6Aa1qClVHaJZszLPzpqHeA== dependencies: "@types/anymatch" "*" "@types/node" "*" @@ -6509,18 +7698,25 @@ dependencies: "@types/node" "*" -"@types/yaml@^1.9.7": - version "1.9.7" - resolved "https://registry.npmjs.org/@types/yaml/-/yaml-1.9.7.tgz#2331f36e0aac91311a63d33eb026c21687729679" - integrity sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA== +"@types/xml2js@^0.4.7": + version "0.4.7" + resolved "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.7.tgz#cd5b6c67bbec741ac625718a76e6cb99bc34365e" + integrity sha512-f5VOKSMEE0O+/L54FHwA/a7vcx9mHeSDM71844yHCOhh8Cin2xQa0UFw0b7Vc5hoZ3Ih6ZHaDobjfLih4tWPNw== dependencies: - yaml "*" + "@types/node" "*" "@types/yargs-parser@*": version "15.0.0" resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" integrity sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw== +"@types/yargs@^13.0.0": + version "13.0.11" + resolved "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.11.tgz#def2f0c93e4bdf2c61d7e34899b17e34be28d3b1" + integrity sha512-NRqD6T4gktUrDi1o1wLH3EKC1o2caCr7/wR87ODcbVITQF106OM3sFN92ysZ++wqelOd1CTzatnOBRDYYG6wGQ== + dependencies: + "@types/yargs-parser" "*" + "@types/yargs@^15.0.0": version "15.0.4" resolved "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.4.tgz#7e5d0f8ca25e9d5849f2ea443cf7c402decd8299" @@ -6539,95 +7735,66 @@ integrity sha512-MBSp62AjB1KrSOI3gX9GekddXU5YYQAVA93+aSl78biBqoSzxg876aQY2KJK5Gnfbpqq7O2cadVX5kPAtBqIXw== "@types/zen-observable@^0.8.0": - version "0.8.0" - resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" - integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== + version "0.8.2" + resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71" + integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg== -"@typescript-eslint/eslint-plugin@^v3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz#7e061338a1383f59edc204c605899f93dc2e2c8f" - integrity sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ== +"@typescript-eslint/eslint-plugin@^v4.14.0": + version "4.14.0" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.14.0.tgz#92db8e7c357ed7d69632d6843ca70b71be3a721d" + integrity sha512-IJ5e2W7uFNfg4qh9eHkHRUCbgZ8VKtGwD07kannJvM5t/GU8P8+24NX8gi3Hf5jST5oWPY8kyV1s/WtfiZ4+Ww== dependencies: - "@typescript-eslint/experimental-utils" "3.10.1" + "@typescript-eslint/experimental-utils" "4.14.0" + "@typescript-eslint/scope-manager" "4.14.0" debug "^4.1.1" functional-red-black-tree "^1.0.1" + lodash "^4.17.15" regexpp "^3.0.0" semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz#e179ffc81a80ebcae2ea04e0332f8b251345a686" - integrity sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw== +"@typescript-eslint/experimental-utils@4.14.0", "@typescript-eslint/experimental-utils@^4.0.1": + version "4.14.0" + resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.14.0.tgz#5aa7b006736634f588a69ee343ca959cd09988df" + integrity sha512-6i6eAoiPlXMKRbXzvoQD5Yn9L7k9ezzGRvzC/x1V3650rUk3c3AOjQyGYyF9BDxQQDK2ElmKOZRD0CbtdkMzQQ== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/types" "3.10.1" - "@typescript-eslint/typescript-estree" "3.10.1" + "@typescript-eslint/scope-manager" "4.14.0" + "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/typescript-estree" "4.14.0" eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/experimental-utils@^4.0.1": - version "4.4.1" - resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.4.1.tgz#40613b9757fa0170de3e0043254dbb077cafac0c" - integrity sha512-Nt4EVlb1mqExW9cWhpV6pd1a3DkUbX9DeyYsdoeziKOpIJ04S2KMVDO+SEidsXRH/XHDpbzXykKcMTLdTXH6cQ== +"@typescript-eslint/parser@^v4.14.0": + version "4.14.0" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.14.0.tgz#62d4cd2079d5c06683e9bfb200c758f292c4dee7" + integrity sha512-sUDeuCjBU+ZF3Lzw0hphTyScmDDJ5QVkyE21pRoBo8iDl7WBtVFS+WDN3blY1CH3SBt7EmYCw6wfmJjF0l/uYg== dependencies: - "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.4.1" - "@typescript-eslint/types" "4.4.1" - "@typescript-eslint/typescript-estree" "4.4.1" - eslint-scope "^5.0.0" - eslint-utils "^2.0.0" - -"@typescript-eslint/parser@^v3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz#1883858e83e8b442627e1ac6f408925211155467" - integrity sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw== - dependencies: - "@types/eslint-visitor-keys" "^1.0.0" - "@typescript-eslint/experimental-utils" "3.10.1" - "@typescript-eslint/types" "3.10.1" - "@typescript-eslint/typescript-estree" "3.10.1" - eslint-visitor-keys "^1.1.0" - -"@typescript-eslint/scope-manager@4.4.1": - version "4.4.1" - resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.4.1.tgz#d19447e60db2ce9c425898d62fa03b2cce8ea3f9" - integrity sha512-2oD/ZqD4Gj41UdFeWZxegH3cVEEH/Z6Bhr/XvwTtGv66737XkR4C9IqEkebCuqArqBJQSj4AgNHHiN1okzD/wQ== - dependencies: - "@typescript-eslint/types" "4.4.1" - "@typescript-eslint/visitor-keys" "4.4.1" - -"@typescript-eslint/types@3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" - integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== - -"@typescript-eslint/types@4.4.1": - version "4.4.1" - resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.4.1.tgz#c507b35cf523bc7ba00aae5f75ee9b810cdabbc1" - integrity sha512-KNDfH2bCyax5db+KKIZT4rfA8rEk5N0EJ8P0T5AJjo5xrV26UAzaiqoJCxeaibqc0c/IvZxp7v2g3difn2Pn3w== - -"@typescript-eslint/typescript-estree@3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz#fd0061cc38add4fad45136d654408569f365b853" - integrity sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w== - dependencies: - "@typescript-eslint/types" "3.10.1" - "@typescript-eslint/visitor-keys" "3.10.1" + "@typescript-eslint/scope-manager" "4.14.0" + "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/typescript-estree" "4.14.0" debug "^4.1.1" - glob "^7.1.6" - is-glob "^4.0.1" - lodash "^4.17.15" - semver "^7.3.2" - tsutils "^3.17.1" -"@typescript-eslint/typescript-estree@4.4.1": - version "4.4.1" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.4.1.tgz#598f6de488106c2587d47ca2462c60f6e2797cb8" - integrity sha512-wP/V7ScKzgSdtcY1a0pZYBoCxrCstLrgRQ2O9MmCUZDtmgxCO/TCqOTGRVwpP4/2hVfqMz/Vw1ZYrG8cVxvN3g== +"@typescript-eslint/scope-manager@4.14.0": + version "4.14.0" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.14.0.tgz#55a4743095d684e1f7b7180c4bac2a0a3727f517" + integrity sha512-/J+LlRMdbPh4RdL4hfP1eCwHN5bAhFAGOTsvE6SxsrM/47XQiPSgF5MDgLyp/i9kbZV9Lx80DW0OpPkzL+uf8Q== dependencies: - "@typescript-eslint/types" "4.4.1" - "@typescript-eslint/visitor-keys" "4.4.1" + "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/visitor-keys" "4.14.0" + +"@typescript-eslint/types@4.14.0": + version "4.14.0" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.14.0.tgz#d8a8202d9b58831d6fd9cee2ba12f8a5a5dd44b6" + integrity sha512-VsQE4VvpldHrTFuVPY1ZnHn/Txw6cZGjL48e+iBxTi2ksa9DmebKjAeFmTVAYoSkTk7gjA7UqJ7pIsyifTsI4A== + +"@typescript-eslint/typescript-estree@4.14.0": + version "4.14.0" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.14.0.tgz#4bcd67486e9acafc3d0c982b23a9ab8ac8911ed7" + integrity sha512-wRjZ5qLao+bvS2F7pX4qi2oLcOONIB+ru8RGBieDptq/SudYwshveORwCVU4/yMAd4GK7Fsf8Uq1tjV838erag== + dependencies: + "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/visitor-keys" "4.14.0" debug "^4.1.1" globby "^11.0.1" is-glob "^4.0.1" @@ -6635,19 +7802,12 @@ semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/visitor-keys@3.10.1": - version "3.10.1" - resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz#cd4274773e3eb63b2e870ac602274487ecd1e931" - integrity sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ== +"@typescript-eslint/visitor-keys@4.14.0": + version "4.14.0" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.0.tgz#b1090d9d2955b044b2ea2904a22496849acbdf54" + integrity sha512-MeHHzUyRI50DuiPgV9+LxcM52FCJFYjJiWHtXlbyC27b80mfOwKeiKI+MHOTEpcpfmoPFm/vvQS88bYIx6PZTA== dependencies: - eslint-visitor-keys "^1.1.0" - -"@typescript-eslint/visitor-keys@4.4.1": - version "4.4.1" - resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.4.1.tgz#1769dc7a9e2d7d2cfd3318b77ed8249187aed5c3" - integrity sha512-H2JMWhLaJNeaylSnMSQFEhT/S/FsJbebQALmoJxMPMxLtlVAMy2uJP/Z543n9IizhjRayLSqoInehCeNW9rWcw== - dependencies: - "@typescript-eslint/types" "4.4.1" + "@typescript-eslint/types" "4.14.0" eslint-visitor-keys "^2.0.0" "@webassemblyjs/ast@1.9.0": @@ -7004,6 +8164,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.12.5, ajv json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^7.0.3: + version "7.0.3" + resolved "https://registry.npmjs.org/ajv/-/ajv-7.0.3.tgz#13ae747eff125cafb230ac504b2406cf371eece2" + integrity sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + alphanum-sort@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" @@ -7060,7 +8230,7 @@ ansi-regex@^3.0.0: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= -ansi-regex@^4.1.0: +ansi-regex@^4.0.0, ansi-regex@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== @@ -7703,26 +8873,16 @@ autoprefixer@^9.7.2: postcss "^7.0.32" postcss-value-parser "^4.1.0" -aws-sdk@^2.817.0: - version "2.817.0" - resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.817.0.tgz#3a97b690b0ec494cf8ee927affb3973cf26abcc8" - integrity sha512-DZIdWpkcqbqsCz0MEskHsyFaqc6Tk9XIFqXAg1AKHbOgC8nU45bz+Y2osX77pU01JkS/G7OhGtGmlKDrOPvFwg== - dependencies: - buffer "4.9.2" - events "1.1.1" - ieee754 "1.1.13" - jmespath "0.15.0" - querystring "0.2.0" - sax "1.2.1" - url "0.10.3" - uuid "3.3.2" - xml2js "0.4.19" - aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= +aws4@^1.11.0: + version "1.11.0" + resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + aws4@^1.8.0: version "1.9.1" resolved "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" @@ -7742,18 +8902,25 @@ axios@^0.20.0: dependencies: follow-redirects "^1.10.0" +axios@^0.21.1: + version "0.21.1" + resolved "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + axobject-query@^2.0.2: version "2.1.2" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.1.2.tgz#2bdffc0371e643e5f03ba99065d5179b9ca79799" integrity sha512-ICt34ZmrVt8UQnvPl6TVyDTkmhXmAyAT4Jh5ugfGUX4MOrZ+U/ZY6/sdylRw3qGNr9Ub5AJsaHeDMzNLehRdOQ== azure-devops-node-api@^10.1.1: - version "10.1.1" - resolved "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-10.1.1.tgz#9016d8935316fff260f5f8fafd81d0caff90a19e" - integrity sha512-P4Hyrh/+Nzc2KXQk73z72/GsenSWIH5o8uiyELqykJYs9TWTVCxVwghoR7lPeiY6QVoXkq2S2KtvAgi5fyjl9w== + version "10.2.1" + resolved "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-10.2.1.tgz#835080164f8c30cec6506c47198b044c053f1f36" + integrity sha512-XuSiUaYpk0tQpd9fD8qfRa5y1IdavupKNVmwxy0w/RhmxG2Wl8uAYnNJchUoWd3Rn9On0mYTCCZSn+UlYdYFSg== dependencies: tunnel "0.0.6" - typed-rest-client "^1.7.3" + typed-rest-client "^1.8.0" underscore "1.8.3" babel-code-frame@^6.22.0: @@ -8355,6 +9522,11 @@ bowser@^1.7.3: resolved "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ== +bowser@^2.11.0: + version "2.11.0" + resolved "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" + integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== + boxen@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" @@ -8584,7 +9756,7 @@ buffer-xor@^1.0.3: resolved "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= -buffer@4.9.2, buffer@^4.3.0: +buffer@^4.3.0: version "4.9.2" resolved "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8" integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg== @@ -8636,6 +9808,16 @@ builtins@^1.0.3: resolved "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88" integrity sha1-y5T662HIaWRR2zZTThQi+U8K7og= +bunyan@^1.8.12: + version "1.8.15" + resolved "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz#8ce34ca908a17d0776576ca1b2f6cbd916e93b46" + integrity sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig== + optionalDependencies: + dtrace-provider "~0.8" + moment "^2.19.3" + mv "~2" + safe-json-stringify "~1" + busboy@^0.3.1: version "0.3.1" resolved "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz#170899274c5bf38aae27d5c62b71268cd585fd1b" @@ -9257,6 +10439,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +clone@2.x: + version "2.1.2" + resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= + clone@^1.0.2: version "1.0.4" resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -9804,9 +10991,9 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core-js@^3.6.5: - version "3.6.5" - resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" - integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== + version "3.8.3" + resolved "https://registry.npmjs.org/core-js/-/core-js-3.8.3.tgz#c21906e1f14f3689f93abcc6e26883550dd92dd0" + integrity sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q== core-js@^2.4.0, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.5: version "2.6.11" @@ -10651,7 +11838,7 @@ debuglog@^1.0.1: resolved "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI= -decamelize-keys@^1.0.0: +decamelize-keys@^1.0.0, decamelize-keys@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9" integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk= @@ -10886,7 +12073,7 @@ detect-indent@^6.0.0: resolved "https://registry.npmjs.org/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== -detect-libc@^1.0.2: +detect-libc@^1.0.2, detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= @@ -10941,6 +12128,11 @@ dicer@0.3.0: dependencies: streamsearch "0.1.2" +diff-sequences@^24.9.0: + version "24.9.0" + resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5" + integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew== + diff-sequences@^26.5.0: version "26.5.0" resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd" @@ -11233,6 +12425,13 @@ downshift@^6.0.6: prop-types "^15.7.2" react-is "^17.0.1" +dtrace-provider@~0.8: + version "0.8.8" + resolved "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz#2996d5490c37e1347be263b423ed7b297fb0d97e" + integrity sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg== + dependencies: + nan "^2.14.0" + duplexer2@~0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" @@ -11685,10 +12884,10 @@ eslint-formatter-friendly@^7.0.0: strip-ansi "5.2.0" text-table "0.2.0" -eslint-import-resolver-node@^0.3.3: - version "0.3.3" - resolved "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.3.tgz#dbaa52b6b2816b50bc6711af75422de808e98404" - integrity sha512-b8crLDo0M5RSe5YG8Pu2DYBj71tSB6OvXkfzwbJU2w7y8P4/yo0MyF8jU26IEuEuHF2K5/gcAJE3LhQGqBBbVg== +eslint-import-resolver-node@^0.3.4: + version "0.3.4" + resolved "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717" + integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA== dependencies: debug "^2.6.9" resolve "^1.13.1" @@ -11719,16 +12918,16 @@ eslint-plugin-graphql@^4.0.0: lodash.without "^4.4.0" eslint-plugin-import@^2.20.2: - version "2.22.0" - resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.0.tgz#92f7736fe1fde3e2de77623c838dd992ff5ffb7e" - integrity sha512-66Fpf1Ln6aIS5Gr/55ts19eUuoDhAbZgnr6UxK5hbDx6l/QgQgx61AePq+BV4PP2uXQFClgMVzep5zZ94qqsxg== + version "2.22.1" + resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702" + integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw== dependencies: array-includes "^3.1.1" array.prototype.flat "^1.2.3" contains-path "^0.1.0" debug "^2.6.9" doctrine "1.5.0" - eslint-import-resolver-node "^0.3.3" + eslint-import-resolver-node "^0.3.4" eslint-module-utils "^2.6.0" has "^1.0.3" minimatch "^3.0.4" @@ -12000,11 +13199,6 @@ eventemitter3@^4.0.0: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== -events@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" - integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= - events@3.1.0, events@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/events/-/events-3.1.0.tgz#84279af1b34cb75aa88bf5ff291f6d0bd9b31a59" @@ -12122,6 +13316,11 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expand-tilde@^2.0.0, expand-tilde@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" @@ -12129,6 +13328,18 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" +expect@^24.8.0: + version "24.9.0" + resolved "https://registry.npmjs.org/expect/-/expect-24.9.0.tgz#b75165b4817074fa4a157794f46fe9f1ba15b6ca" + integrity sha512-wvVAx8XIol3Z5m9zvZXiyZOQ+sRJqNTIm6sGjdWlaZIeupQGO3WbYI+15D/AmEwZywL6wtJkbAbJtzkOfBuR0Q== + dependencies: + "@jest/types" "^24.9.0" + ansi-styles "^3.2.0" + jest-get-type "^24.9.0" + jest-matcher-utils "^24.9.0" + jest-message-util "^24.9.0" + jest-regex-util "^24.9.0" + expect@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz#c6b996bf26bf3fe18b67b2d0f51fc981ba934417" @@ -12285,6 +13496,11 @@ extsprintf@^1.2.0: resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +fast-base64-decode@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz#b434a0dd7d92b12b43f26819300d2dafb83ee418" + integrity sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q== + fast-deep-equal@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" @@ -12354,6 +13570,11 @@ fast-text-encoding@^1.0.0: resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== +fast-xml-parser@^3.16.0: + version "3.17.6" + resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.17.6.tgz#4f5df8cf927c3e59a10362abcfb7335c34bc5c5f" + integrity sha512-40WHI/5d2MOzf1sD2bSaTXlPn1lueJLAX6j1xH5dSAr6tNeut8B9ktEL6sjAK9yVON4uNj9//axOdBJUuruCzw== + fastest-stable-stringify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-1.0.1.tgz#9122d406d4c9d98bea644a6b6853d5874b87b028" @@ -12598,12 +13819,12 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -find-versions@^3.2.0: - version "3.2.0" - resolved "https://registry.npmjs.org/find-versions/-/find-versions-3.2.0.tgz#10297f98030a786829681690545ef659ed1d254e" - integrity sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww== +find-versions@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/find-versions/-/find-versions-4.0.0.tgz#3c57e573bf97769b8cb8df16934b627915da4965" + integrity sha512-wgpWy002tA+wgmO27buH/9KzyEOQnKsG/R0yrcjPT9BOFm0zRBVQbZ95nRGXWMywS8YR5knRbpohio0bcJABxQ== dependencies: - semver-regex "^2.0.0" + semver-regex "^3.1.2" find-yarn-workspace-root2@1.2.16: version "1.2.16" @@ -13168,10 +14389,10 @@ git-url-parse@^11.1.2: dependencies: git-up "^4.0.0" -git-url-parse@^11.4.3: - version "11.4.3" - resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.3.tgz#1610284edf1f14964180f5b3399ec68b692cfd87" - integrity sha512-LZTTk0nqJnKN48YRtOpR8H5SEfp1oM2tls90NuZmBxN95PnCvmuXGzqQ4QmVirBgKx2KPYfPGteX3/raWjKenQ== +git-url-parse@^11.4.4: + version "11.4.4" + resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.4.tgz#5d747debc2469c17bc385719f7d0427802d83d77" + integrity sha512-Y4o9o7vQngQDIU9IjyCmRJBin5iYjI5u9ZITnddRZpD7dcCFQj2sL2XuMNbLRE4b4B/4ENPsp2Q8P44fjAZ0Pw== dependencies: git-up "^4.0.0" @@ -13182,6 +14403,11 @@ gitconfiglocal@^1.0.0: dependencies: ini "^1.3.2" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= + github-slugger@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" @@ -13243,6 +14469,17 @@ glob@7.1.6, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glo once "^1.3.0" path-is-absolute "^1.0.0" +glob@^6.0.1: + version "6.0.4" + resolved "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" + integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI= + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + global-dirs@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201" @@ -13435,23 +14672,6 @@ got@^11.5.2: p-cancelable "^2.0.0" responselike "^2.0.0" -got@^11.6.2: - version "11.7.0" - resolved "https://registry.npmjs.org/got/-/got-11.7.0.tgz#a386360305571a74548872e674932b4ef70d3b24" - integrity sha512-7en2XwH2MEqOsrK0xaKhbWibBoZqy+f1RSUoIeF1BLcnf+pyQdDsljWMfmOh+QKJwuvDIiKx38GtPh5wFdGGjg== - dependencies: - "@sindresorhus/is" "^3.1.1" - "@szmarczak/http-timer" "^4.0.5" - "@types/cacheable-request" "^6.0.1" - "@types/responselike" "^1.0.0" - cacheable-lookup "^5.0.3" - cacheable-request "^7.0.1" - decompress-response "^6.0.0" - http2-wrapper "^1.0.0-beta.5.2" - lowercase-keys "^2.0.0" - p-cancelable "^2.0.0" - responselike "^2.0.0" - got@^11.7.0, got@^11.8.0: version "11.8.0" resolved "https://registry.npmjs.org/got/-/got-11.8.0.tgz#be0920c3586b07fd94add3b5b27cb28f49e6545f" @@ -13715,6 +14935,11 @@ har-validator@~5.1.3: ajv "^6.5.5" har-schema "^2.0.0" +hard-rejection@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" + integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA== + harmony-reflect@^1.4.6: version "1.6.1" resolved "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.1.tgz#c108d4f2bb451efef7a37861fdbdae72c9bdefa9" @@ -13879,18 +15104,6 @@ highlight.js@^10.4.1, highlight.js@~10.4.0: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== -history@^4.9.0: - version "4.10.1" - resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" - integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== - dependencies: - "@babel/runtime" "^7.1.2" - loose-envify "^1.2.0" - resolve-pathname "^3.0.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - value-equal "^1.0.1" - history@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" @@ -13907,7 +15120,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -14217,17 +15430,17 @@ humanize-ms@^1.2.1: ms "^2.0.0" husky@^4.2.3: - version "4.3.6" - resolved "https://registry.npmjs.org/husky/-/husky-4.3.6.tgz#ebd9dd8b9324aa851f1587318db4cccb7665a13c" - integrity sha512-o6UjVI8xtlWRL5395iWq9LKDyp/9TE7XMOTvIpEVzW638UcGxTmV5cfel6fsk/jbZSTlvfGVJf2svFtybcIZag== + version "4.3.8" + resolved "https://registry.npmjs.org/husky/-/husky-4.3.8.tgz#31144060be963fd6850e5cc8f019a1dfe194296d" + integrity sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow== dependencies: chalk "^4.0.0" ci-info "^2.0.0" compare-versions "^3.6.0" cosmiconfig "^7.0.0" - find-versions "^3.2.0" + find-versions "^4.0.0" opencollective-postinstall "^2.0.2" - pkg-dir "^4.2.0" + pkg-dir "^5.0.0" please-upgrade-node "^3.2.0" slash "^3.0.0" which-pm-runs "^1.0.0" @@ -14263,11 +15476,6 @@ identity-obj-proxy@3.0.0: dependencies: harmony-reflect "^1.4.6" -ieee754@1.1.13: - version "1.1.13" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" - integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== - ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -14310,10 +15518,10 @@ immer@1.10.0: resolved "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== -immer@^7.0.9: - version "7.0.9" - resolved "https://registry.npmjs.org/immer/-/immer-7.0.9.tgz#28e7552c21d39dd76feccd2b800b7bc86ee4a62e" - integrity sha512-Vs/gxoM4DqNAYR7pugIxi0Xc8XAun/uy7AQu4fLLqaTBHxjOP9pJ266Q9MWA/ly4z6rAFZbvViOtihxUZ7O28A== +immer@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" + integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== immutable@>=3.8.2, immutable@^3.8.1, immutable@^3.8.2, immutable@^3.x.x: version "3.8.2" @@ -14963,6 +16171,11 @@ is-plain-object@^3.0.0: dependencies: isobject "^4.0.0" +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + is-potential-custom-element-name@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397" @@ -15113,11 +16326,6 @@ is-yarn-global@^0.3.0: resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= - isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -15262,9 +16470,9 @@ jake@^10.6.1: minimatch "^3.0.4" jenkins@^0.28.0: - version "0.28.0" - resolved "https://registry.npmjs.org/jenkins/-/jenkins-0.28.0.tgz#72d6fcc452145403b34f6d4ecbd877ee1ab77fca" - integrity sha512-EGzzZcyFwXBCPZZoDNvZTPmZOqaHALfAStNfCF37oh+Mc6G/e0MwIuQjx5kMEynTXR9bF5EwLiuMTiTf5kHk5g== + version "0.28.1" + resolved "https://registry.npmjs.org/jenkins/-/jenkins-0.28.1.tgz#f7951798ee5d2bb501a831979b6b5ecc1a922a64" + integrity sha512-gcC4QUrP4VzdqOMHoVzh36XlJprxJkI2HGLQSY7w84KoCTVNDcR/8O00tYyXp9vrZOx4wl5zCXLVKMgH2IoyJQ== dependencies: papi "^0.29.0" @@ -15327,6 +16535,16 @@ jest-css-modules@^2.1.0: dependencies: identity-obj-proxy "3.0.0" +jest-diff@^24.9.0: + version "24.9.0" + resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz#931b7d0d5778a1baf7452cb816e325e3724055da" + integrity sha512-qMfrTs8AdJE2iqrTp0hzh7kTd2PQWrsFyj9tORoKmu32xjPjeE4NyjVRDz8ybYwqS2ik8N4hsIpiVTyFeo2lBQ== + dependencies: + chalk "^2.0.1" + diff-sequences "^24.9.0" + jest-get-type "^24.9.0" + pretty-format "^24.9.0" + jest-diff@^26.0.0: version "26.6.1" resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c" @@ -15398,6 +16616,11 @@ jest-esm-transformer@^1.0.0: "@babel/core" "^7.4.4" "@babel/plugin-transform-modules-commonjs" "^7.4.4" +jest-get-type@^24.9.0: + version "24.9.0" + resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.9.0.tgz#1684a0c8a50f2e4901b6644ae861f579eed2ef0e" + integrity sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q== + jest-get-type@^26.3.0: version "26.3.0" resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" @@ -15456,6 +16679,16 @@ jest-leak-detector@^26.6.2: jest-get-type "^26.3.0" pretty-format "^26.6.2" +jest-matcher-utils@^24.9.0: + version "24.9.0" + resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz#f5b3661d5e628dffe6dd65251dfdae0e87c3a073" + integrity sha512-OZz2IXsu6eaiMAwe67c1T+5tUAtQyQx27/EMEkbFAGiw52tB9em+uGbzpcgYVpA8wl0hlxKPZxrly4CXU/GjHA== + dependencies: + chalk "^2.0.1" + jest-diff "^24.9.0" + jest-get-type "^24.9.0" + pretty-format "^24.9.0" + jest-matcher-utils@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz#8e6fd6e863c8b2d31ac6472eeb237bc595e53e7a" @@ -15466,6 +16699,20 @@ jest-matcher-utils@^26.6.2: jest-get-type "^26.3.0" pretty-format "^26.6.2" +jest-message-util@^24.9.0: + version "24.9.0" + resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-24.9.0.tgz#527f54a1e380f5e202a8d1149b0ec872f43119e3" + integrity sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw== + dependencies: + "@babel/code-frame" "^7.0.0" + "@jest/test-result" "^24.9.0" + "@jest/types" "^24.9.0" + "@types/stack-utils" "^1.0.1" + chalk "^2.0.1" + micromatch "^3.1.10" + slash "^2.0.0" + stack-utils "^1.0.1" + jest-message-util@^26.6.2: version "26.6.2" resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.6.2.tgz#58173744ad6fc0506b5d21150b9be56ef001ca07" @@ -15494,6 +16741,11 @@ jest-pnp-resolver@^1.2.2: resolved "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== +jest-regex-util@^24.9.0: + version "24.9.0" + resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-24.9.0.tgz#c13fb3380bde22bf6575432c493ea8fe37965636" + integrity sha512-05Cmb6CuxaA+Ys6fjr3PhvV3bGQmO+2p2La4hFbU+W5uOc479f7FdLXUWXw4pYMAhhSZIuKHwSXSu6CsSBAXQA== + jest-regex-util@^26.0.0: version "26.0.0" resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28" @@ -15660,6 +16912,14 @@ jest-watcher@^26.6.2: jest-util "^26.6.2" string-length "^4.0.1" +jest-when@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/jest-when/-/jest-when-3.1.0.tgz#d041191b8d4a8ad3fd9c4dc2eda3237eaeec4004" + integrity sha512-oLANZfF60yxheShbbke3+XXosZhge8Va2xiwIddF8DF5l996Ldy8bL6hT319ovSyjrhBWLOEESKfANyM0Eo/SQ== + dependencies: + bunyan "^1.8.12" + expect "^24.8.0" + jest-worker@^26.2.1: version "26.3.0" resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-26.3.0.tgz#7c8a97e4f4364b4f05ed8bca8ca0c24de091871f" @@ -15687,22 +16947,17 @@ jest@^26.0.1: import-local "^3.0.2" jest-cli "^26.6.3" -jmespath@0.15.0: - version "0.15.0" - resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" - integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc= - jose@^1.27.1: - version "1.27.1" - resolved "https://registry.npmjs.org/jose/-/jose-1.27.1.tgz#a1de2ecb5b3ae1ae28f0d9d0cc536349ada27ec8" - integrity sha512-VyHM6IJPw0TTGqHVNlPWg16/ASDPAmcChcLqSb3WNBvwWFoWPeFqlmAUCm8/oIG1GjZwAlUDuRKFfycowarcVA== + version "1.28.0" + resolved "https://registry.npmjs.org/jose/-/jose-1.28.0.tgz#0803f8c71f43cd293a9d931c555c30531f5ca5dc" + integrity sha512-JmfDRzt/HSj8ipd9TsDtEHoLUnLYavG+7e8F6s1mx2jfVSfXOTaFQsJUydbjJpTnTDHP1+yKL9Ke7ktS/a0Eiw== dependencies: "@panva/asn1.js" "^1.0.0" jose@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/jose/-/jose-2.0.2.tgz#fb22385b80c658cc7a0cae05b7086c04c6be49f4" - integrity sha512-yD93lsiMA1go/qxSY/vXWBodmIZJIxeB7QhFi8z1yQ3KUwKENqI9UA8VCHlQ5h3x1zWuWZjoY87ByQzkQbIrQg== + version "2.0.3" + resolved "https://registry.npmjs.org/jose/-/jose-2.0.3.tgz#9c931ab3e13e2d16a5b9e6183e60b2fc40a8e1b8" + integrity sha512-L+RlDgjO0Tk+Ki6/5IXCSEnmJCV8iMFZoBuEgu2vPQJJ4zfG/k3CAqZUMKDYNRHIDyy0QidJpOvX0NgpsAqFlw== dependencies: "@panva/asn1.js" "^1.0.0" @@ -15889,6 +17144,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -15937,7 +17197,7 @@ json3@^3.3.2: resolved "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== -json5@2.x, json5@^2.1.1, json5@^2.1.2: +json5@2.x, json5@^2.1.1, json5@^2.1.2, json5@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== @@ -16172,6 +17432,19 @@ jwt-decode@*, jwt-decode@^3.1.0: resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== +kafkajs@^1.16.0-beta.6: + version "1.16.0-beta.6" + resolved "https://registry.npmjs.org/kafkajs/-/kafkajs-1.16.0-beta.6.tgz#650f4d16abd60516aa0c375be613368391216df1" + integrity sha512-R4DT3s7oCAoxdq3tN8w2WkUxLXcZgLQSjQVSmEZpEmT6hVdZ5AUZWqyLYr1IRbQowsJK4Z7eWJAk68J0JiRlUw== + +keytar@^5.4.0: + version "5.6.0" + resolved "https://registry.npmjs.org/keytar/-/keytar-5.6.0.tgz#7b5d4bd043d17211163640be6c4a27a49b12bb39" + integrity sha512-ueulhshHSGoryfRXaIvTj0BV1yB0KddBGhGoqCxSN9LR1Ks1GKuuCdVhF+2/YOs5fMl6MlTI9On1a4DHDXoTow== + dependencies: + nan "2.14.1" + prebuild-install "5.3.3" + keyv@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -16217,7 +17490,7 @@ kind-of@^5.0.0: resolved "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== -kind-of@^6.0.0, kind-of@^6.0.2: +kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3: version "6.0.3" resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== @@ -16840,7 +18113,7 @@ longest-streak@^2.0.0: resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -16917,6 +18190,11 @@ lru-queue@0.1: dependencies: es5-ext "~0.10.2" +luxon@^1.25.0: + version "1.25.0" + resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72" + integrity sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ== + macos-release@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" @@ -17277,6 +18555,23 @@ meow@^5.0.0: trim-newlines "^2.0.0" yargs-parser "^10.0.0" +meow@^6.0.0: + version "6.1.1" + resolved "https://registry.npmjs.org/meow/-/meow-6.1.1.tgz#1ad64c4b76b2a24dfb2f635fddcadf320d251467" + integrity sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg== + dependencies: + "@types/minimist" "^1.2.0" + camelcase-keys "^6.2.2" + decamelize-keys "^1.1.0" + hard-rejection "^2.1.0" + minimist-options "^4.0.2" + normalize-package-data "^2.5.0" + read-pkg-up "^7.0.1" + redent "^3.0.0" + trim-newlines "^3.0.0" + type-fest "^0.13.1" + yargs-parser "^18.1.3" + merge-deep@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.2.tgz#f39fa100a4f1bd34ff29f7d2bf4508fbb8d83ad2" @@ -17301,10 +18596,10 @@ merge2@^1.2.3, merge2@^1.3.0: resolved "https://registry.npmjs.org/merge2/-/merge2-1.3.0.tgz#5b366ee83b2f1582c48f87e47cf1a9352103ca81" integrity sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw== -merge@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" - integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== +merge@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/merge/-/merge-2.1.0.tgz#91fff62458ba2eca378dd395fa85f1690bf87f60" + integrity sha512-TcuhVDV+e6X457MQAm7xIb19rWhZuEDEho7RrwxMpQ/3GhD5sDlnP188gjQQuweXHy9igdke5oUtVOXX1X8Sxg== methods@^1.0.0, methods@^1.1.1, methods@^1.1.2, methods@~1.1.2: version "1.1.2" @@ -17471,14 +18766,6 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= -mini-create-react-context@^0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040" - integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA== - dependencies: - "@babel/runtime" "^7.5.5" - tiny-warning "^1.0.3" - mini-css-extract-plugin@^0.9.0: version "0.9.0" resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e" @@ -17499,7 +18786,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: resolved "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= -minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.4: +"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== @@ -17514,6 +18801,15 @@ minimist-options@^3.0.1: arrify "^1.0.1" is-plain-obj "^1.1.0" +minimist-options@^4.0.2: + version "4.1.0" + resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619" + integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A== + dependencies: + arrify "^1.0.1" + is-plain-obj "^1.1.0" + kind-of "^6.0.3" + minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: version "1.2.5" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" @@ -17653,7 +18949,7 @@ modify-values@^1.0.0: resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment@^2.25.3, moment@^2.26.0, moment@^2.27.0: +moment@^2.19.3, moment@^2.25.3, moment@^2.26.0, moment@^2.27.0: version "2.29.1" resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== @@ -17710,6 +19006,13 @@ ms@2.1.2, ms@^2.0.0, ms@^2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +msal@^1.0.2: + version "1.4.4" + resolved "https://registry.npmjs.org/msal/-/msal-1.4.4.tgz#3f9b5a4442aa711c12ab8e88b8ed89b293f99711" + integrity sha512-aOBD/L6jAsizDFzKxxvXxH0FEDjp6Inr3Ufi/Y2o7KCFKN+akoE2sLeszEb/0Y3VxHxK0F0ea7xQ/HHTomKivw== + dependencies: + tslib "^1.9.3" + msw@^0.20.5: version "0.20.5" resolved "https://registry.npmjs.org/msw/-/msw-0.20.5.tgz#b6141080c0d8b17c451d9ca36c28cc47b4ac487a" @@ -17796,6 +19099,15 @@ mute-stream@0.0.8, mute-stream@~0.0.4: resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +mv@~2: + version "2.1.1" + resolved "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz#ae6ce0d6f6d5e0a4f7d893798d03c1ea9559b6a2" + integrity sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI= + dependencies: + mkdirp "~0.5.1" + ncp "~2.0.0" + rimraf "~2.4.0" + mz@^2.5.0, mz@^2.7.0: version "2.7.0" resolved "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" @@ -17805,7 +19117,7 @@ mz@^2.5.0, mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.12.1, nan@^2.14.0: +nan@2.14.1, nan@^2.12.1, nan@^2.14.0: version "2.14.1" resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== @@ -17846,6 +19158,11 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + native-url@^0.2.6: version "0.2.6" resolved "https://registry.npmjs.org/native-url/-/native-url-0.2.6.tgz#ca1258f5ace169c716ff44eccbddb674e10399ae" @@ -17858,6 +19175,11 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +ncp@~2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M= + needle@^2.2.1: version "2.4.1" resolved "https://registry.npmjs.org/needle/-/needle-2.4.1.tgz#14af48732463d7475696f937626b1b993247a56a" @@ -17905,7 +19227,7 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" -nock@*, nock@^13.0.5: +nock@^13.0.5: version "13.0.5" resolved "https://registry.npmjs.org/nock/-/nock-13.0.5.tgz#a618c6f86372cb79fac04ca9a2d1e4baccdb2414" integrity sha512-1ILZl0zfFm2G4TIeJFW0iHknxr2NyA+aGCMTjDVUsBY4CkMRispF1pfIYkTRdAR/3Bg+UzdEuK0B6HczMQZcCg== @@ -17915,11 +19237,25 @@ nock@*, nock@^13.0.5: lodash.set "^4.3.2" propagate "^2.0.0" +node-abi@^2.7.0: + version "2.19.3" + resolved "https://registry.npmjs.org/node-abi/-/node-abi-2.19.3.tgz#252f5dcab12dad1b5503b2d27eddd4733930282d" + integrity sha512-9xZrlyfvKhWme2EXFKQhZRp1yNWT/uI1luYPr3sFl+H4keYY4xR+1jO7mvTTijIsHf1M+QDe9uWuKeEpLInIlg== + dependencies: + semver "^5.4.1" + node-addon-api@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" integrity sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA== +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + node-dir@^0.1.10: version "0.1.17" resolved "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" @@ -18087,9 +19423,9 @@ node-request-interceptor@^0.5.1: headers-utils "^1.2.0" nodemon@^2.0.2: - version "2.0.6" - resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz#1abe1937b463aaf62f0d52e2b7eaadf28cc2240d" - integrity sha512-4I3YDSKXg6ltYpcnZeHompqac4E6JeAMpGm8tJnB9Y3T0ehasLa4139dJOcCrB93HHrUMsCrKtoAlXTqT5n4AQ== + version "2.0.7" + resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.7.tgz#6f030a0a0ebe3ea1ba2a38f71bf9bab4841ced32" + integrity sha512-XHzK69Awgnec9UzHr1kc8EomQh4sjTQ8oRf8TsGrSmHDx9/UmiGG9E/mM3BuTfNeFwdNBvrqQq/RHL0xIeyFOA== dependencies: chokidar "^3.2.2" debug "^3.2.6" @@ -18102,6 +19438,11 @@ nodemon@^2.0.2: undefsafe "^2.0.3" update-notifier "^4.1.0" +noop-logger@^0.1.1: + version "0.1.1" + resolved "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2" + integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI= + "nopt@2 || 3": version "3.0.6" resolved "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" @@ -18239,7 +19580,7 @@ npm-run-path@^4.0.0: dependencies: path-key "^3.0.0" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.2, npmlog@^4.1.2: +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.1, npmlog@^4.0.2, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -18487,6 +19828,14 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +open@^7.0.0: + version "7.3.1" + resolved "https://registry.npmjs.org/open/-/open-7.3.1.tgz#111119cb919ca1acd988f49685c4fdd0f4755356" + integrity sha512-f2wt9DCBKKjlFbjzGb8MOAW8LH8F0mrs1zc7KTjAJ9PZNQbfenzWbNP1VZJvw6ICMG9r14Ah6yfwPn7T7i646A== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + open@^7.0.2, open@^7.0.3: version "7.2.1" resolved "https://registry.npmjs.org/open/-/open-7.2.1.tgz#07b0ade11a43f2a8ce718480bdf3d7563a095195" @@ -18507,7 +19856,7 @@ opencollective-postinstall@^2.0.2: resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz#5657f1bede69b6e33a45939b061eb53d3c6c3a89" integrity sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw== -openid-client@*, openid-client@^4.2.1: +openid-client@^4.1.1, openid-client@^4.2.1: version "4.2.1" resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.2.1.tgz#8200c0ab6a3b8e954727dfa790847dc5cb8999c2" integrity sha512-07eOcJeMH3ZHNvx5DVMZQmy3vZSTQqKSSunbtM1pXb+k5LBPi5hMum1vJCFReXlo4wuLEqZ/OgbsZvXPhbGRtA== @@ -18521,20 +19870,6 @@ openid-client@*, openid-client@^4.2.1: oidc-token-hash "^5.0.0" p-any "^3.0.0" -openid-client@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.1.1.tgz#3e8a25584c4292e9b9b03e60358f5549fb85197a" - integrity sha512-/qch3I3v8UtO0A7wVgyXJJjGX/knR8bv06DQpLuKQqLG5u4AHcgusGuVKPKAcneLZvHKbKovF2+3e2ngXyuudA== - dependencies: - base64url "^3.0.1" - got "^11.6.2" - jose "^2.0.2" - lru-cache "^6.0.0" - make-error "^1.3.6" - object-hash "^2.0.1" - oidc-token-hash "^5.0.0" - p-any "^3.0.0" - opn@^5.5.0: version "5.5.0" resolved "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" @@ -18566,7 +19901,7 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -ora@*, ora@^4.0.3: +ora@^4.0.3: version "4.0.4" resolved "https://registry.npmjs.org/ora/-/ora-4.0.4.tgz#e8da697cc5b6a47266655bf68e0fb588d29a545d" integrity sha512-77iGeVU1cIdRhgFzCK8aw1fbtT1B/iZAvWjS+l/o1x0RShMgxHUZaD2yDpWsNCPwXg9z1ZA78Kbdvr8kBmG/Ww== @@ -18694,7 +20029,7 @@ p-finally@^2.0.0: resolved "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== -p-limit@3.0.2, p-limit@^3.0.2: +p-limit@3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.0.2.tgz#1664e010af3cadc681baafd3e2a437be7b0fb5fe" integrity sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg== @@ -18715,7 +20050,7 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-limit@^3.0.1: +p-limit@^3.0.1, p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== @@ -19097,17 +20432,16 @@ passport-onelogin-oauth@^0.0.1: pkginfo "0.2.x" uid2 "0.0.3" -"passport-saml@^1.3.5, <1.4.0": - version "1.3.5" - resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.5.tgz#747f2c8bb8b9fed41e8cd14586df5aa83e8a8996" - integrity sha512-HFamiqgGiMRCbUBm3wx02WYWKb6ojke0WJHrg4QXI8tx35HrTmDiY8MksUXhouJtEkfcRwXjBL2cSEpRQ6+PgQ== +passport-saml@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-2.0.2.tgz#41c459f0f4c4cf0374b161bf3f9ab008fcacb8fe" + integrity sha512-e8rT/nWEY3YalktzZlGXvnj6mLDQD4R+atfStCF4nHCbqK5IUpip+Bj2Wzbm9v5F/KI497k57kNXkHCLfDSVjA== dependencies: debug "^3.1.0" passport-strategy "*" - q "^1.5.0" - xml-crypto "^1.4.0" + xml-crypto "^2.0.0" xml-encryption "1.2.1" - xml2js "0.4.x" + xml2js "^0.4.23" xmlbuilder "^11.0.0" xmldom "0.1.x" @@ -19193,13 +20527,6 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= -path-to-regexp@^1.7.0: - version "1.8.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" - integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== - dependencies: - isarray "0.0.1" - path-type@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -19435,6 +20762,13 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +pkg-dir@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" + integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== + dependencies: + find-up "^5.0.0" + pkg-up@3.1.0, pkg-up@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" @@ -19926,6 +21260,27 @@ postgres-interval@^1.1.0: dependencies: xtend "^4.0.0" +prebuild-install@5.3.3: + version "5.3.3" + resolved "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.3.tgz#ef4052baac60d465f5ba6bf003c9c1de79b9da8e" + integrity sha512-GV+nsUXuPW2p8Zy7SarF/2W/oiK8bFQgJcncoJ0d7kRpekEA0ftChjfEaF9/Y+QJEc/wFR7RAEa8lYByuUIe2g== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.0" + mkdirp "^0.5.1" + napi-build-utils "^1.0.1" + node-abi "^2.7.0" + noop-logger "^0.1.1" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + which-pm-runs "^1.0.0" + precond@0.2: version "0.2.3" resolved "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac" @@ -19961,7 +21316,7 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^1.18.2: +prettier@^1.18.2, prettier@^1.19.1: version "1.19.1" resolved "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== @@ -19984,6 +21339,16 @@ pretty-error@^2.1.1: renderkid "^2.0.1" utila "~0.4" +pretty-format@^24.9.0: + version "24.9.0" + resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" + integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== + dependencies: + "@jest/types" "^24.9.0" + ansi-regex "^4.0.0" + ansi-styles "^3.2.0" + react-is "^16.8.4" + pretty-format@^26.0.0, pretty-format@^26.4.2, pretty-format@^26.6.1: version "26.6.1" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8" @@ -20183,7 +21548,7 @@ pseudomap@^1.0.2: resolved "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.28: +psl@^1.1.28, psl@^1.1.33: version "1.8.0" resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== @@ -20261,7 +21626,7 @@ pupa@^2.0.1: dependencies: escape-goat "^2.0.0" -q@^1.1.2, q@^1.5.0, q@^1.5.1: +q@^1.1.2, q@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= @@ -20276,6 +21641,11 @@ qs@^6.5.1, qs@^6.6.0, qs@^6.9.1, qs@^6.9.4: resolved "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== +qs@^6.7.0: + version "6.9.6" + resolved "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz#26ed3c8243a431b2924aca84cc90471f35d5a0ee" + integrity sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ== + qs@~6.5.2: version "6.5.2" resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -20644,7 +22014,7 @@ react-inspector@^5.0.1: is-dom "^1.1.0" prop-types "^15.6.1" -react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -20699,6 +22069,13 @@ react-motion@^0.5.2: prop-types "^15.5.8" raf "^3.1.0" +react-native-get-random-values@^1.4.0: + version "1.5.0" + resolved "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.5.0.tgz#91cda18f0e66e3d9d7660ba80c61c914030c1e05" + integrity sha512-LK+Wb8dEimJkd/dub7qziDmr9Tw4chhpzVeQ6JDo4czgfG4VXbptRyOMdu8503RiMF6y9pTH6ZUTkrrpprqT7w== + dependencies: + fast-base64-decode "^1.0.0" + react-popper-tooltip@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/react-popper-tooltip/-/react-popper-tooltip-3.1.1.tgz#329569eb7b287008f04fcbddb6370452ad3f9eac" @@ -20754,7 +22131,7 @@ react-resize-detector@^2.3.0: prop-types "^15.6.0" resize-observer-polyfill "^1.5.0" -react-router-dom@6.0.0-beta.0: +react-router-dom@6.0.0-beta.0, react-router-dom@^6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-beta.0.tgz#9dcc8555365f22f7fbd09f26b6b82543f3eb97d6" integrity sha512-36yNNGMT8RB9FRPL9nKJi6HKDkgOakU+o/2hHpSzR6e37gN70MpOU6QQlmif4oAWWBwjyGc3ZNOMFCsFuHUY5w== @@ -20762,35 +22139,6 @@ react-router-dom@6.0.0-beta.0: prop-types "^15.7.2" react-router "6.0.0-beta.0" -react-router-dom@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" - integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - loose-envify "^1.3.1" - prop-types "^15.6.2" - react-router "5.2.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - -react-router@5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" - integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - hoist-non-react-statics "^3.1.0" - loose-envify "^1.3.1" - mini-create-react-context "^0.4.0" - path-to-regexp "^1.7.0" - prop-types "^15.6.2" - react-is "^16.6.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - react-router@6.0.0-beta.0, react-router@^6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-beta.0.tgz#3e11f39b6ded4412c2fed9e4f989dd4c8156724d" @@ -21306,12 +22654,7 @@ regenerator-runtime@^0.11.0: resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.5: - version "0.13.5" - resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" - integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA== - -regenerator-runtime@^0.13.7: +regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.5, regenerator-runtime@^0.13.7: version "0.13.7" resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== @@ -21574,6 +22917,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" @@ -21641,11 +22989,6 @@ resolve-from@^4.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve-pathname@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" - integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== - resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -21779,6 +23122,13 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +rimraf@~2.4.0: + version "2.4.5" + resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" + integrity sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto= + dependencies: + glob "^6.0.1" + ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.2" resolved "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" @@ -21828,10 +23178,10 @@ rollup-plugin-postcss@^3.1.1: safe-identifier "^0.4.1" style-inject "^0.3.0" -rollup-plugin-typescript2@^0.27.3: - version "0.27.3" - resolved "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.27.3.tgz#cd9455ac026d325b20c5728d2cc54a08a771b68b" - integrity sha512-gmYPIFmALj9D3Ga1ZbTZAKTXq1JKlTQBtj299DXhqYz9cL3g/AQfUvbb2UhH+Nf++cCq941W2Mv7UcrcgLzJJg== +rollup-plugin-typescript2@^0.29.0: + version "0.29.0" + resolved "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.29.0.tgz#b7ad83f5241dbc5bdf1e98d9c3fca005ffe39e1a" + integrity sha512-YytahBSZCIjn/elFugEGQR5qTsVhxhUwGZIsA9TmrSsC88qroGo65O5HZP/TTArH2dm0vUmYWhKchhwi2wL9bw== dependencies: "@rollup/pluginutils" "^3.1.0" find-cache-dir "^3.3.1" @@ -21919,6 +23269,11 @@ safe-identifier@^0.4.1: resolved "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.1.tgz#b6516bf72594f03142b5f914f4c01842ccb1b678" integrity sha512-73tOz5TXsq3apuCc3vC8c9QRhhdNZGiBhHmPPjqpH4TO5oCDqk8UIsDcSs/RG6dYcFAkOOva0pqHS3u7hh7XXA== +safe-json-stringify@~1: + version "1.2.0" + resolved "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz#356e44bc98f1f93ce45df14bcd7c01cda86e0afd" + integrity sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg== + safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" @@ -21958,11 +23313,6 @@ sanitize-html@^1.27.0: srcset "^2.0.1" xtend "^4.0.1" -sax@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" - integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= - sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -22040,10 +23390,10 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -semver-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz#a93c2c5844539a770233379107b38c7b4ac9d338" - integrity sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw== +semver-regex@^3.1.2: + version "3.1.2" + resolved "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.2.tgz#34b4c0d361eef262e07199dbef316d0f2ab11807" + integrity sha512-bXWyL6EAKOJa81XG1OZ/Yyuq+oT0b2YLlxx7c+mrdYPaPbnj6WgVULXhinMIeZGufuUBu/eVRqXEhiv4imfwxA== "semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: version "5.7.1" @@ -22722,6 +24072,13 @@ stack-trace@0.0.x: resolved "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= +stack-utils@^1.0.1: + version "1.0.4" + resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.4.tgz#4b600971dcfc6aed0cbdf2a8268177cc916c87c8" + integrity sha512-IPDJfugEGbfizBwBZRZ3xpccMdRyP5lqsBWXGQWimVjua/ccLCeMOAVjlc1R7LxFjo5sEDhyNIXd8mo/AiDS9w== + dependencies: + escape-string-regexp "^2.0.0" + stack-utils@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.2.tgz#5cf48b4557becb4638d0bc4f21d23f5d19586593" @@ -23203,9 +24560,9 @@ subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.16: ws "^5.2.0" sucrase@^3.16.0: - version "3.16.0" - resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.16.0.tgz#19b5b886ccca270dd5ca12ff060eeaf0b599735f" - integrity sha512-ovVuswxV5TayCPXfTk8bgBgk6uNRvsinIkEpq0J6zS1xXCx5N/LLGcbsKdRhqn/ToZylMX6+yXaR1LSn1I42Pg== + version "3.17.0" + resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.17.0.tgz#d9fe5d7e359d884cdb31130358fbdfc18bfb4c24" + integrity sha512-wtiqaokYRjFSSrv8fQu7pThKTIZSLwiffW+PHQG52hlI8eJO47v1tXbKt6fYb8Z1kCyuCkNH9etpTUebb7g+pA== dependencies: commander "^4.0.0" glob "7.1.6" @@ -23409,6 +24766,16 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== +tar-fs@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + tar-fs@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz#e44086c1c60d31a4f0cf893b1c4e155dabfae9e2" @@ -23700,7 +25067,7 @@ tiny-emitter@^2.0.0: resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tiny-invariant@^1.0.2, tiny-invariant@^1.0.6: +tiny-invariant@^1.0.6: version "1.1.0" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== @@ -23710,7 +25077,7 @@ tiny-merge-patch@^0.1.2: resolved "https://registry.npmjs.org/tiny-merge-patch/-/tiny-merge-patch-0.1.2.tgz#2e8ded19c56ea15dbd3ad4ed5db1c8e5ad544c3c" integrity sha1-Lo3tGcVuoV29OtTtXbHI5a1UTDw= -tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: +tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== @@ -23844,6 +25211,15 @@ tough-cookie@^3.0.1: psl "^1.1.28" punycode "^2.1.1" +tough-cookie@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" + integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.1.2" + tr46@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" @@ -23883,6 +25259,11 @@ trim-newlines@^2.0.0: resolved "https://registry.npmjs.org/trim-newlines/-/trim-newlines-2.0.0.tgz#b403d0b91be50c331dfc4b82eeceb22c3de16d20" integrity sha1-tAPQuRvlDDMd/EuC7s6yLD3hbSA= +trim-newlines@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30" + integrity sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA== + trim-off-newlines@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz#9f9ba9d9efa8764c387698bcbfeb2c848f11adb3" @@ -24000,17 +25381,17 @@ tslib@2.0.0: resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz#18d13fc2dce04051e20f074cc8387fd8089ce4f3" integrity sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g== -tslib@2.0.1, tslib@^2.0.0, tslib@~2.0.0, tslib@~2.0.1: +tslib@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e" integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ== -tslib@^1.10.0, tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: - version "1.13.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" - integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== +tslib@^1.10.0, tslib@^1.11.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: + version "1.14.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1: +tslib@^2.0.0, tslib@^2.0.1, tslib@~2.0.0, tslib@~2.0.1: version "2.0.3" resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== @@ -24027,7 +25408,7 @@ tty-browserify@0.0.0: resolved "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= -tty-table@^2.7.0: +tty-table@^2.8.10: version "2.8.13" resolved "https://registry.npmjs.org/tty-table/-/tty-table-2.8.13.tgz#d484a416381973eaebbdf19c79136b390e5c6d70" integrity sha512-eVV/+kB6fIIdx+iUImhXrO22gl7f6VmmYh0Zbu6C196fe1elcHXd7U6LcLXu0YoVPc2kNesWiukYcdK8ZmJ6aQ== @@ -24046,7 +25427,7 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" -tunnel@0.0.6: +tunnel@0.0.6, tunnel@^0.0.6: version "0.0.6" resolved "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== @@ -24080,6 +25461,11 @@ type-fest@^0.11.0: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" integrity sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ== +type-fest@^0.13.1: + version "0.13.1" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934" + integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== + type-fest@^0.3.0: version "0.3.1" resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1" @@ -24113,10 +25499,10 @@ type@^2.0.0: resolved "https://registry.npmjs.org/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" integrity sha512-KBt58xCHry4Cejnc2ISQAF7QY+ORngsWfxezO68+12hKV6lQY8P/psIkcbjeHWn7MqcgciWJyCCevFMJdIXpow== -typed-rest-client@^1.7.3: - version "1.7.3" - resolved "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.7.3.tgz#1beb263b86b14d34596f6127c6172dd5fd652e7b" - integrity sha512-CwTpx/TkRHGZoHkJhBcp4X8K3/WtlzSHVQR0OIFnt10j4tgy4ypgq/SrrgVpA1s6tAL49Q6J3R5C0Cgfh2ddqA== +typed-rest-client@^1.8.0: + version "1.8.0" + resolved "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.0.tgz#3b6c22a7cc31b665ec1e4bedb3482ebe12e2fbe6" + integrity sha512-Nu1MrdH6ECrRW5gHoRAdubgCs4oH6q5/J76jsEC8bVDfvVoVPkigukPalhMHPwb7ZvpsZqPptd5zpt/QdtrdBw== dependencies: qs "^6.9.1" tunnel "0.0.6" @@ -24134,10 +25520,10 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript-json-schema@^0.45.0: - version "0.45.1" - resolved "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.45.1.tgz#d3c1feea85b3bfeaf7e42a8c3bab80f2dc121a5d" - integrity sha512-Iz1NKVtJi09iZSzXj3J350GekBrZ1TiNw6Cbf42jLOGyooh9BWoi8XP6XlTIMOI3aMD8XxuL9hEvIEq8FD/WUw== +typescript-json-schema@^0.47.0: + version "0.47.0" + resolved "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.47.0.tgz#84dde5460b127c6774da81bf70b23c7e04857b13" + integrity sha512-A6NVwSOTSsNDHfaqDcDeKwwyXEeKqBHoAr20jcetnYj4e8C6zVFofAVhAuwsBXCRYiWEE/lyHrcxpsSpbIk0Mg== dependencies: "@types/json-schema" "^7.0.6" glob "^7.1.6" @@ -24327,6 +25713,14 @@ unist-util-visit@^2.0.0: unist-util-is "^4.0.0" unist-util-visit-parents "^3.0.0" +universal-github-app-jwt@^1.0.1: + version "1.1.0" + resolved "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-1.1.0.tgz#0abaa876101cdf1d3e4c546be2768841c0c1b514" + integrity sha512-3b+ocAjjz4JTyqaOT+NNBd5BtTuvJTxWElIoeHSVelUV9J3Jp7avmQTdLKCaoqi/5Ox2o/q+VK19TJ233rVXVQ== + dependencies: + "@types/jsonwebtoken" "^8.3.3" + jsonwebtoken "^8.5.1" + universal-user-agent@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557" @@ -24346,7 +25740,7 @@ universal-user-agent@^6.0.0: resolved "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== -universalify@^0.1.0: +universalify@^0.1.0, universalify@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== @@ -24469,14 +25863,6 @@ url-parse@^1.4.3, url-parse@^1.4.7: querystringify "^2.1.1" requires-port "^1.0.0" -url@0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" - integrity sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ= - dependencies: - punycode "1.3.2" - querystring "0.2.0" - url@^0.11.0, url@~0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -24575,12 +25961,7 @@ utils-merge@1.0.1, utils-merge@1.x.x: resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" - integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== - -uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2, uuid@^3.4.0: +uuid@^3.0.0, uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2, uuid@^3.4.0: version "3.4.0" resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== @@ -24666,11 +26047,6 @@ validate.io-number@^1.0.3: resolved "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz#f63ffeda248bf28a67a8d48e0e3b461a1665baf8" integrity sha1-9j/+2iSL8opnqNSODjtGGhZluvg= -value-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" - integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== - vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -25261,12 +26637,7 @@ ws@^6.0.0, ws@^6.1.2, ws@^6.2.1: dependencies: async-limiter "~1.0.0" -ws@^7.2.3: - version "7.3.0" - resolved "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz#4b2f7f219b3d3737bc1a2fbf145d825b94d38ffd" - integrity sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w== - -ws@^7.3.1: +ws@^7.2.3, ws@^7.3.1: version "7.3.1" resolved "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8" integrity sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA== @@ -25288,10 +26659,10 @@ xml-but-prettier@^1.0.1: dependencies: repeat-string "^1.5.2" -xml-crypto@^1.4.0: - version "1.5.3" - resolved "https://registry.npmjs.org/xml-crypto/-/xml-crypto-1.5.3.tgz#a8f500b90f0dfaf0efa3331c345ecb0fff993c34" - integrity sha512-uHkmpUtX15xExe5iimPmakAZN+6CqIvjmaJTy4FwqGzaTjrKRBNeqMh8zGEzVNgW0dk6beFYpyQSgqV/J6C5xA== +xml-crypto@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/xml-crypto/-/xml-crypto-2.0.0.tgz#54cd268ad9d31930afcf7092cbb664258ca9e826" + integrity sha512-/a04qr7RpONRZHOxROZ6iIHItdsQQjN3sj8lJkYDDss8tAkEaAs0VrFjb3tlhmS5snQru5lTs9/5ISSMdPDHlg== dependencies: xmldom "0.1.27" xpath "0.0.27" @@ -25311,15 +26682,7 @@ xml-name-validator@^3.0.0: resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== -xml2js@0.4.19: - version "0.4.19" - resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" - integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q== - dependencies: - sax ">=0.6.0" - xmlbuilder "~9.0.1" - -xml2js@0.4.x: +xml2js@^0.4.19, xml2js@^0.4.23: version "0.4.23" resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== @@ -25332,11 +26695,6 @@ xmlbuilder@^11.0.0, xmlbuilder@~11.0.0: resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== -xmlbuilder@~9.0.1: - version "9.0.7" - resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" - integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= - xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" @@ -25410,7 +26768,7 @@ yaml-ast-parser@0.0.43, yaml-ast-parser@^0.0.43: resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz#e8a23e6fb4c38076ab92995c5dca33f3d3d7c9bb" integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== -yaml@*, yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: +yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: version "1.10.0" resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== @@ -25443,7 +26801,7 @@ yargs-parser@^15.0.1: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^18.1.2: +yargs-parser@^18.1.2, yargs-parser@^18.1.3: version "18.1.3" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==